Skip to main content

cerno_tui/
app.rs

1//! Application state and the transitions the key handler drives.
2//!
3//! Everything that decides *what* happens lives here, and nothing in this module draws or talks
4//! to a terminal. That is what lets the interesting parts — building a request out of drafts,
5//! moving focus, reporting a failure against the question that caused it — be tested directly.
6
7use crate::draft::{Kind, QuestionDraft};
8use crate::editor::Editor;
9use crate::session::Session;
10use cerno_sdk::{Answers, Client, Error, SystemOne};
11use cerno_types::ErrorCode;
12use ratatui_textarea::TextArea;
13use std::cell::Cell;
14use std::time::Instant;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum Focus {
18    State,
19    Questions,
20    Answers,
21}
22
23impl Focus {
24    pub fn next(self) -> Self {
25        match self {
26            Focus::State => Focus::Questions,
27            Focus::Questions => Focus::Answers,
28            Focus::Answers => Focus::State,
29        }
30    }
31
32    pub fn previous(self) -> Self {
33        self.next().next()
34    }
35
36    /// Whether typing a printable character goes into a text field rather than acting as a
37    /// shortcut. `q` must quit from the list and type a letter in the state box.
38    pub fn is_text_input(self) -> bool {
39        matches!(self, Focus::State)
40    }
41}
42
43#[derive(Debug)]
44pub enum Status {
45    Idle,
46    Sending {
47        started: Instant,
48        /// Which send this is. A result carrying any other number belongs to a request that was
49        /// cancelled, and must not land as the answer to this one.
50        generation: u64,
51    },
52    Failed(Failure),
53}
54
55#[derive(Debug, Clone)]
56pub struct Failure {
57    pub message: String,
58    pub code: Option<ErrorCode>,
59    /// The question the service blamed, so the list can point at it.
60    pub question_id: Option<String>,
61}
62
63impl Failure {
64    pub fn from_error(error: &Error) -> Self {
65        match error {
66            Error::Api { code, response, .. } => Self {
67                message: response.message.clone(),
68                code: Some(*code),
69                question_id: response.question_id.clone(),
70            },
71            // A newer service's code: this build cannot name it, but the message and the
72            // question it blames are as good as any other.
73            Error::UnknownCode {
74                message,
75                question_id,
76                ..
77            } => Self {
78                message: message.clone(),
79                code: None,
80                question_id: question_id.clone(),
81            },
82            other => Self {
83                message: other.to_string(),
84                code: None,
85                question_id: None,
86            },
87        }
88    }
89}
90
91pub struct App {
92    pub state: TextArea<'static>,
93    pub questions: Vec<QuestionDraft>,
94    /// Ranges over `0..=questions.len()`: one past the last question is the "add" row.
95    pub selected: usize,
96    pub focus: Focus,
97
98    pub model: Option<String>,
99    /// Aliases offered by the service, for cycling with `m`.
100    pub models: Vec<String>,
101    pub calibration: Option<f64>,
102
103    pub status: Status,
104    pub answers: Option<Answers>,
105    /// `(id, question)` for the request in flight, taken when it was sent.
106    sent: Vec<(String, String)>,
107    /// `(id, question)` as they were when `answers` was asked. The drafts may have been edited
108    /// since, and an answer shown under a question it was never asked would mislead.
109    answered: Vec<(String, String)>,
110    /// Whether `answers` belongs to an older request than the form now describes.
111    pub stale: bool,
112    /// Lines of the answers pane scrolled past the top.
113    pub answers_scroll: u16,
114    /// How far the answers pane can scroll, as the last draw found it. Only drawing knows how
115    /// many lines the answers wrap to, so the renderer records it here and scrolling stops
116    /// there, instead of running on into blank space that then takes as many presses to undo.
117    pub answers_max_scroll: Cell<u16>,
118
119    pub editor: Option<Editor>,
120    pub show_help: bool,
121
122    pub url: String,
123    pub healthy: Option<bool>,
124    pub should_quit: bool,
125    /// Advances once per tick; drives the spinner.
126    pub tick: u64,
127    /// Counts sends, so a result can be matched to the request that produced it.
128    generation: u64,
129}
130
131impl App {
132    pub fn new(session: Session, url: String) -> Self {
133        Self {
134            state: TextArea::new(split_lines(&session.state)),
135            questions: session.questions,
136            selected: 0,
137            focus: Focus::State,
138            model: session.model,
139            models: Vec::new(),
140            calibration: session.calibration,
141            status: Status::Idle,
142            answers: None,
143            sent: Vec::new(),
144            answered: Vec::new(),
145            stale: false,
146            answers_scroll: 0,
147            answers_max_scroll: Cell::new(0),
148            editor: None,
149            show_help: false,
150            url,
151            healthy: None,
152            should_quit: false,
153            tick: 0,
154            generation: 0,
155        }
156    }
157
158    pub fn state_text(&self) -> String {
159        self.state.lines().join("\n")
160    }
161
162    pub fn to_session(&self) -> Session {
163        Session {
164            state: self.state_text(),
165            model: self.model.clone(),
166            calibration: self.calibration,
167            questions: self.questions.clone(),
168        }
169    }
170
171    // -- questions ---------------------------------------------------------------------------
172
173    /// Whether the cursor is on the "add question" row after the last question.
174    pub fn on_add_row(&self) -> bool {
175        self.selected >= self.questions.len()
176    }
177
178    pub fn select_next(&mut self) {
179        let rows = self.questions.len() + 1;
180        self.selected = (self.selected + 1) % rows;
181    }
182
183    pub fn select_previous(&mut self) {
184        let rows = self.questions.len() + 1;
185        self.selected = (self.selected + rows - 1) % rows;
186    }
187
188    /// Open the editor on a new question, offering an id that does not collide with an
189    /// existing one. The field itself starts empty; see [`Editor::adding`].
190    pub fn add_question(&mut self) {
191        self.editor = Some(Editor::adding(self.next_id()));
192    }
193
194    /// Edit the selected question, or start a new one when the cursor is on the add row.
195    pub fn edit_selected(&mut self) {
196        if self.on_add_row() {
197            self.add_question();
198        } else if let Some(draft) = self.questions.get(self.selected) {
199            self.editor = Some(Editor::editing(draft.clone(), self.selected));
200        }
201    }
202
203    pub fn delete_selected(&mut self) {
204        if self.selected < self.questions.len() {
205            self.questions.remove(self.selected);
206            self.selected = self.selected.saturating_sub(usize::from(
207                self.selected >= self.questions.len() && !self.questions.is_empty(),
208            ));
209            if self.questions.is_empty() {
210                self.selected = 0;
211            }
212            self.mark_stale();
213        }
214    }
215
216    /// Take the draft out of the editor and put it where it belongs.
217    pub fn commit_editor(&mut self) {
218        let Some(editor) = self.editor.take() else {
219            return;
220        };
221        let (draft, existing) = editor.finish();
222
223        match existing {
224            Some(index) if index < self.questions.len() => self.questions[index] = draft,
225            _ => {
226                self.questions.push(draft);
227                self.selected = self.questions.len() - 1;
228            }
229        }
230        self.mark_stale();
231    }
232
233    pub fn cancel_editor(&mut self) {
234        self.editor = None;
235    }
236
237    /// `q1`, `q2`, … skipping any id already taken.
238    fn next_id(&self) -> String {
239        (1..)
240            .map(|n| format!("q{n}"))
241            .find(|candidate| !self.questions.iter().any(|q| &q.id == candidate))
242            .expect("the sequence is unbounded")
243    }
244
245    // -- model and calibration ---------------------------------------------------------------
246
247    /// Step through the service's aliases, then back to "the server's default".
248    pub fn cycle_model(&mut self) {
249        if self.models.is_empty() {
250            return;
251        }
252
253        let next = match &self.model {
254            None => Some(self.models[0].clone()),
255            Some(current) => match self.models.iter().position(|m| m == current) {
256                Some(index) if index + 1 < self.models.len() => {
257                    Some(self.models[index + 1].clone())
258                }
259                // Past the end, fall back to letting the service choose.
260                Some(_) => None,
261                None => Some(self.models[0].clone()),
262            },
263        };
264
265        self.model = next;
266        self.mark_stale();
267    }
268
269    // -- sending -----------------------------------------------------------------------------
270
271    /// Assemble the request this form describes. See [`Session::build`].
272    pub fn build<'a>(&self, client: &'a Client) -> SystemOne<'a> {
273        self.to_session().build(client)
274    }
275
276    pub fn is_sending(&self) -> bool {
277        matches!(self.status, Status::Sending { .. })
278    }
279
280    /// Enter the sending state and return the generation the result must carry to be accepted.
281    pub fn begin_send(&mut self) -> u64 {
282        self.generation += 1;
283        self.sent = self
284            .questions
285            .iter()
286            .map(|q| (q.id.clone(), q.question.trim().to_string()))
287            .collect();
288        self.status = Status::Sending {
289            started: Instant::now(),
290            generation: self.generation,
291        };
292        self.generation
293    }
294
295    /// Take a request's result. Returns whether it was accepted, i.e. whether it belonged to the
296    /// request currently in flight.
297    pub fn finish_send(&mut self, generation: u64, result: Result<Answers, Error>) -> bool {
298        // A cancelled request can still land: the task is aborted, but a result already on the
299        // channel arrives anyway. Checking only "are we sending" is not enough — after Esc and
300        // a second send, the first result would arrive while the second is in flight and be
301        // shown as its answer. The generation says which request a result belongs to.
302        match self.status {
303            Status::Sending {
304                generation: current,
305                ..
306            } if current == generation => {}
307            _ => return false,
308        }
309
310        match result {
311            Ok(answers) => {
312                self.answers = Some(answers);
313                self.answers_scroll = 0;
314                self.answered = std::mem::take(&mut self.sent);
315                self.stale = false;
316                self.status = Status::Idle;
317            }
318            Err(error) => {
319                // The previous answers stay on screen, marked stale: a failed run should not
320                // erase the run you were comparing it against.
321                self.stale = self.answers.is_some();
322                self.status = Status::Failed(Failure::from_error(&error));
323            }
324        }
325        true
326    }
327
328    /// The ids of the answers on screen, in the order their questions were asked.
329    ///
330    /// The response keys its answers by id, which sorts `q10` before `q2` and `team` before
331    /// `urgent`. Anything the service answered that was not in the sent list — which a normal
332    /// round trip never produces — follows in the response's order rather than being hidden.
333    pub fn answer_order(&self) -> Vec<&str> {
334        let Some(answers) = &self.answers else {
335            return Vec::new();
336        };
337        let mut order: Vec<&str> = self
338            .answered
339            .iter()
340            .map(|(id, _)| id.as_str())
341            .filter(|id| answers.get(id).is_ok())
342            .collect();
343        for id in answers.ids() {
344            if !order.contains(&id) {
345                order.push(id);
346            }
347        }
348        order
349    }
350
351    /// Scroll the answers pane by `lines`, down for positive, within what the last draw allows.
352    pub fn scroll_answers(&mut self, lines: i32) {
353        let max = i32::from(self.answers_max_scroll.get());
354        let next = (i32::from(self.answers_scroll) + lines).clamp(0, max);
355        self.answers_scroll = u16::try_from(next).unwrap_or(0);
356    }
357
358    /// The question text an answer on screen was asked with.
359    pub fn question_for(&self, id: &str) -> Option<&str> {
360        self.answered
361            .iter()
362            .find(|(asked, _)| asked == id)
363            .map(|(_, question)| question.as_str())
364    }
365
366    pub fn cancel_send(&mut self) {
367        if self.is_sending() {
368            self.status = Status::Idle;
369        }
370    }
371
372    pub fn failure(&self) -> Option<&Failure> {
373        match &self.status {
374            Status::Failed(failure) => Some(failure),
375            _ => None,
376        }
377    }
378
379    /// Whether the question at `index` is the one the service refused.
380    pub fn is_blamed(&self, index: usize) -> bool {
381        match (
382            self.failure().and_then(|f| f.question_id.as_deref()),
383            self.questions.get(index),
384        ) {
385            (Some(blamed), Some(draft)) => draft.id == blamed,
386            _ => false,
387        }
388    }
389
390    /// The form has changed since the answers on screen were produced.
391    pub fn mark_stale(&mut self) {
392        if self.answers.is_some() {
393            self.stale = true;
394        }
395    }
396}
397
398/// Split text into the lines a `TextArea` expects, never producing an empty vector.
399fn split_lines(text: &str) -> Vec<String> {
400    if text.is_empty() {
401        return vec![String::new()];
402    }
403    text.split('\n').map(str::to_string).collect()
404}
405
406/// A kind's short label, re-exported for the renderer.
407pub fn kind_label(kind: Kind) -> &'static str {
408    kind.label()
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    fn app() -> App {
416        App::new(Session::default(), "http://cerno.test".into())
417    }
418
419    fn draft(id: &str) -> QuestionDraft {
420        QuestionDraft {
421            id: id.into(),
422            question: "Is this urgent?".into(),
423            ..Default::default()
424        }
425    }
426
427    #[test]
428    fn focus_cycles_in_both_directions() {
429        assert_eq!(Focus::State.next(), Focus::Questions);
430        assert_eq!(Focus::Answers.next(), Focus::State);
431
432        for focus in [Focus::State, Focus::Questions, Focus::Answers] {
433            assert_eq!(focus.next().previous(), focus);
434        }
435    }
436
437    /// `q` has to quit from the list and type a letter in the state box.
438    #[test]
439    fn only_the_state_box_swallows_printable_keys() {
440        assert!(Focus::State.is_text_input());
441        assert!(!Focus::Questions.is_text_input());
442        assert!(!Focus::Answers.is_text_input());
443    }
444
445    #[test]
446    fn selection_wraps_at_both_ends() {
447        let mut app = app();
448        app.questions = vec![draft("a"), draft("b"), draft("c")];
449
450        app.select_next();
451        assert_eq!(app.selected, 1);
452
453        app.select_previous();
454        app.select_previous();
455        assert_eq!(
456            app.selected, 3,
457            "wraps backwards past zero onto the add row"
458        );
459        assert!(app.on_add_row());
460
461        app.select_next();
462        assert_eq!(app.selected, 0, "wraps forwards past the add row");
463    }
464
465    /// The add row is a list position like any other: Enter on it adds rather than edits.
466    #[test]
467    fn editing_the_add_row_adds_a_question() {
468        let mut app = app();
469        app.questions = vec![draft("a")];
470        app.selected = 1;
471
472        app.edit_selected();
473        assert!(!app.editor.as_ref().unwrap().is_editing());
474        app.commit_editor();
475
476        assert_eq!(app.questions.len(), 2);
477        assert_eq!(
478            app.questions[0].id, "a",
479            "the existing question is untouched"
480        );
481    }
482
483    #[test]
484    fn deleting_on_the_add_row_deletes_nothing() {
485        let mut app = app();
486        app.questions = vec![draft("a")];
487        app.selected = 1;
488
489        app.delete_selected();
490
491        assert_eq!(app.questions.len(), 1);
492    }
493
494    /// An answer is shown under the question it was asked with, not whatever the draft says now.
495    #[test]
496    fn answers_keep_the_question_as_it_was_sent() {
497        let mut app = app();
498        app.questions = vec![draft("a")];
499
500        let generation = app.begin_send();
501        app.questions[0].question = "edited while in flight".into();
502        assert!(app.finish_send(generation, Ok(no_answers())));
503        app.questions[0].question = "edited afterwards".into();
504
505        assert_eq!(app.question_for("a"), Some("Is this urgent?"));
506        assert_eq!(app.question_for("missing"), None);
507    }
508
509    /// The pane lists answers the way the form lists questions, not the way ids sort.
510    #[test]
511    fn answers_are_ordered_as_their_questions_were_asked() {
512        let mut app = app();
513        app.questions = vec![draft("q2"), draft("q10"), draft("urgent"), draft("team")];
514
515        let generation = app.begin_send();
516        let response: cerno_sdk::SystemOneResponse = serde_json::from_value(serde_json::json!({
517            "answers": {
518                "q10": noul_answer(), "q2": noul_answer(),
519                "team": noul_answer(), "urgent": noul_answer()
520            },
521            "model": "m",
522            "usage": {"input_tokens": 0, "questions": 4},
523            "timing_ms": {"total": 1}
524        }))
525        .unwrap();
526        assert!(app.finish_send(generation, Ok(Answers::from(response))));
527
528        assert_eq!(app.answer_order(), ["q2", "q10", "urgent", "team"]);
529    }
530
531    #[test]
532    fn scrolling_stays_within_what_was_drawn() {
533        let mut app = app();
534        app.answers_max_scroll.set(5);
535
536        app.scroll_answers(-3);
537        assert_eq!(app.answers_scroll, 0, "not above the top");
538
539        app.scroll_answers(3);
540        assert_eq!(app.answers_scroll, 3);
541
542        app.scroll_answers(100);
543        assert_eq!(app.answers_scroll, 5, "not past the last line");
544    }
545
546    fn noul_answer() -> serde_json::Value {
547        serde_json::json!({
548            "type": "noul", "noul": 0.5, "raw_logprobs": {"A": -0.7, "B": -0.7},
549            "truncated": false, "truncated_labels": [], "label_mass": 1.0
550        })
551    }
552
553    /// An empty list must not panic or produce an out-of-range index.
554    #[test]
555    fn selection_is_safe_with_no_questions() {
556        let mut app = app();
557
558        app.select_next();
559        app.select_previous();
560        app.delete_selected();
561
562        assert_eq!(app.selected, 0);
563        assert!(app.questions.is_empty());
564    }
565
566    #[test]
567    fn deleting_the_last_question_moves_the_selection_back() {
568        let mut app = app();
569        app.questions = vec![draft("a"), draft("b")];
570        app.selected = 1;
571
572        app.delete_selected();
573
574        assert_eq!(app.questions.len(), 1);
575        assert_eq!(app.selected, 0, "selection must stay in range");
576    }
577
578    #[test]
579    fn a_new_question_gets_an_id_that_is_free() {
580        let mut app = app();
581        app.questions = vec![draft("q1"), draft("q3")];
582
583        app.add_question();
584        app.commit_editor();
585
586        assert_eq!(app.questions.last().unwrap().id, "q2");
587    }
588
589    #[test]
590    fn editing_replaces_in_place_rather_than_appending() {
591        let mut app = app();
592        app.questions = vec![draft("a"), draft("b")];
593        app.selected = 0;
594
595        app.edit_selected();
596        app.editor
597            .as_mut()
598            .unwrap()
599            .set(crate::editor::Field::Question, "changed");
600        app.commit_editor();
601
602        assert_eq!(app.questions.len(), 2);
603        assert_eq!(app.questions[0].question, "changed");
604        assert_eq!(app.questions[1].id, "b");
605    }
606
607    #[test]
608    fn cancelling_the_editor_changes_nothing() {
609        let mut app = app();
610        app.questions = vec![draft("a")];
611
612        app.edit_selected();
613        app.editor
614            .as_mut()
615            .unwrap()
616            .set(crate::editor::Field::Question, "discarded");
617        app.cancel_editor();
618
619        assert_eq!(app.questions[0].question, "Is this urgent?");
620        assert!(app.editor.is_none());
621    }
622
623    #[test]
624    fn cycling_models_ends_at_the_service_default() {
625        let mut app = app();
626        app.models = vec!["small".into(), "large".into()];
627
628        assert_eq!(app.model, None);
629        app.cycle_model();
630        assert_eq!(app.model.as_deref(), Some("small"));
631        app.cycle_model();
632        assert_eq!(app.model.as_deref(), Some("large"));
633        app.cycle_model();
634        assert_eq!(app.model, None, "wraps back to letting the service choose");
635    }
636
637    #[test]
638    fn cycling_does_nothing_when_the_service_listed_no_models() {
639        let mut app = app();
640
641        app.cycle_model();
642
643        assert_eq!(app.model, None);
644    }
645
646    /// A failure names a question, and the list has to be able to point at it.
647    #[test]
648    fn the_blamed_question_is_the_one_the_service_named() {
649        let mut app = app();
650        app.questions = vec![draft("urgent"), draft("team")];
651        app.status = Status::Failed(Failure {
652            message: "a choice may have at most 20 options".into(),
653            code: Some(ErrorCode::TooManyOptions),
654            question_id: Some("team".into()),
655        });
656
657        assert!(!app.is_blamed(0));
658        assert!(app.is_blamed(1));
659    }
660
661    #[test]
662    fn a_failure_without_a_question_blames_nobody() {
663        let mut app = app();
664        app.questions = vec![draft("a")];
665        app.status = Status::Failed(Failure {
666            message: "host unreachable".into(),
667            code: Some(ErrorCode::HostUnavailable),
668            question_id: None,
669        });
670
671        assert!(!app.is_blamed(0));
672    }
673
674    #[test]
675    fn state_text_round_trips_through_the_editor_widget() {
676        let session = Session {
677            state: "line one\nline two".into(),
678            ..Default::default()
679        };
680
681        let app = App::new(session, "http://cerno.test".into());
682
683        assert_eq!(app.state_text(), "line one\nline two");
684    }
685
686    #[test]
687    fn a_session_round_trips_through_the_app() {
688        let session = Session {
689            state: "Ticket: printer jammed.".into(),
690            model: Some("small".into()),
691            calibration: Some(2.5),
692            questions: vec![draft("urgent")],
693        };
694
695        let app = App::new(session.clone(), "http://cerno.test".into());
696
697        assert_eq!(app.to_session(), session);
698    }
699
700    /// Answers on screen belong to the request that produced them; changing the form must say so
701    /// rather than leave a stale result looking current.
702    #[test]
703    fn editing_the_form_marks_existing_answers_stale() {
704        let mut app = app();
705        assert!(!app.stale, "nothing to be stale about yet");
706
707        app.questions = vec![draft("a")];
708        app.mark_stale();
709        assert!(!app.stale, "no answers on screen, so nothing goes stale");
710
711        let generation = app.begin_send();
712        assert!(app.finish_send(generation, Ok(no_answers())));
713        assert!(!app.stale, "fresh answers are current");
714
715        app.delete_selected();
716        assert!(
717            app.stale,
718            "a changed form must mark the answers on screen stale"
719        );
720    }
721
722    fn no_answers() -> Answers {
723        Answers::from(cerno_sdk::SystemOneResponse {
724            answers: Default::default(),
725            model: "m".into(),
726            usage: cerno_types::Usage {
727                input_tokens: 0,
728                questions: 0,
729            },
730            timing_ms: cerno_types::Timing { total: 0 },
731        })
732    }
733}
734
735#[cfg(test)]
736mod late_result_tests {
737    use super::*;
738    use cerno_sdk::Error;
739
740    /// Esc means the answer you were waiting for is no longer wanted. A result that was already
741    /// in flight when the task was aborted must not repaint the screen behind your back.
742    #[test]
743    fn a_result_arriving_after_a_cancel_is_dropped() {
744        let mut app = App::new(Session::default(), "http://cerno.test".into());
745        let cancelled = app.begin_send();
746        app.cancel_send();
747
748        app.finish_send(cancelled, Err(Error::MissingAnswer("late".into())));
749
750        assert!(
751            app.failure().is_none(),
752            "a cancelled request left an error behind"
753        );
754        assert!(matches!(app.status, Status::Idle));
755    }
756
757    /// Esc, then a second send: the first request's result can still be on the channel, and it
758    /// arrives while the second one is in flight. It must not be taken for the second's answer.
759    #[test]
760    fn a_cancelled_result_is_not_taken_for_the_next_requests_answer() {
761        let mut app = App::new(Session::default(), "http://cerno.test".into());
762        let cancelled = app.begin_send();
763        app.cancel_send();
764        let current = app.begin_send();
765
766        app.finish_send(cancelled, Err(Error::MissingAnswer("late".into())));
767
768        assert!(app.failure().is_none(), "the stale result was shown");
769        assert!(app.is_sending(), "the second request is still in flight");
770
771        app.finish_send(current, Err(Error::MissingAnswer("current".into())));
772
773        assert!(
774            app.failure().is_some(),
775            "the current request's result was dropped"
776        );
777    }
778}