1use 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 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 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 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 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 pub selected: usize,
96 pub focus: Focus,
97
98 pub model: Option<String>,
99 pub models: Vec<String>,
101 pub calibration: Option<f64>,
102
103 pub status: Status,
104 pub answers: Option<Answers>,
105 sent: Vec<(String, String)>,
107 answered: Vec<(String, String)>,
110 pub stale: bool,
112 pub answers_scroll: u16,
114 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 pub tick: u64,
127 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 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 pub fn add_question(&mut self) {
191 self.editor = Some(Editor::adding(self.next_id()));
192 }
193
194 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 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 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 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 Some(_) => None,
261 None => Some(self.models[0].clone()),
262 },
263 };
264
265 self.model = next;
266 self.mark_stale();
267 }
268
269 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 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 pub fn finish_send(&mut self, generation: u64, result: Result<Answers, Error>) -> bool {
298 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 self.stale = self.answers.is_some();
322 self.status = Status::Failed(Failure::from_error(&error));
323 }
324 }
325 true
326 }
327
328 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 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 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 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 pub fn mark_stale(&mut self) {
392 if self.answers.is_some() {
393 self.stale = true;
394 }
395 }
396}
397
398fn 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
406pub 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}