Skip to main content

jev_repl/
app.rs

1//! REPL state: the transcript, the input line, the session being built, and what each command does.
2
3use std::time::Duration;
4
5use ratatui::crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
6use ratatui::style::{Modifier, Style};
7use ratatui::text::{Line, Span};
8use serde_json::Value;
9use tokio::sync::mpsc::UnboundedSender;
10use typesafe::{Answer, Client, ListModelsResponse, Question, SystemOneResponse};
11
12use crate::builder::{Builder, Outcome};
13use crate::editor::{self, Editor};
14use crate::format::*;
15use crate::session::{self, Session};
16use crate::{codegen, cost, headless, highlight, lessons, mock, presets, sketch, trend, words};
17
18/// Everything that can move the app forward.
19pub enum Msg {
20    Term(Event),
21    Tick,
22    Answered(Box<typesafe::Result<SystemOneResponse>>, Duration),
23    Models(Box<typesafe::Result<ListModelsResponse>>),
24    /// What a live `:trend` came back with: a response per turn, or the first error.
25    Trend(Box<typesafe::Result<Vec<SystemOneResponse>>>),
26}
27
28pub const COMMANDS: &[(&str, &str)] = &[
29    (":help", "this list; :help concepts for the model itself"),
30    (":lesson", "guided track — :lesson next|prev|list|<n>"),
31    (
32        ":try",
33        "put the current lesson's command in the input line (Ctrl-T)",
34    ),
35    (":preset", "load a ready-made session — :preset list"),
36    (
37        ":state",
38        "set the state — :state <text> | :state json {…} | :state clear",
39    ),
40    (
41        ":turn",
42        "grow the state into a conversation — :turn <who>: <text> | :turn list | :turn drop",
43    ),
44    (
45        ":trend",
46        "every question after each turn of the conversation, as one line apiece",
47    ),
48    (":noul", ":noul <name> <instructions> [| yes: …] [| no: …]"),
49    (
50        ":choice",
51        ":choice <name> <instructions> | label=desc | label=desc",
52    ),
53    (":score", ":score <name> <instructions> | level | level | …"),
54    (
55        ":raw",
56        ":raw <name> {\"type\": …} — a hand-built question object",
57    ),
58    (
59        ":build",
60        "builder mode: a form with a live JSON preview (Ctrl-B)",
61    ),
62    (
63        ":sketch",
64        "sketch mode: the whole request as one page of text (Ctrl-K) — :sketch show prints it",
65    ),
66    (":questions", "list what will be sent"),
67    (":rm", ":rm <name> — drop one question"),
68    (":reset", "empty the session"),
69    (":ask", "send it (or press Enter on an empty line)"),
70    (":json", "the exact request body this session POSTs"),
71    (":last", "the last raw response body"),
72    (
73        ":cost",
74        "what a call costs — :cost <in>/<out> sets dollars per million tokens",
75    ),
76    (":rust", "this session as a program against the SDK"),
77    (
78        ":threshold",
79        ":threshold <0-1> — what counts as a yes for a noul",
80    ),
81    (":model", ":model [name] — per-session model"),
82    (":models", "models the account can use"),
83    (":timeout", ":timeout <seconds> — per-attempt timeout"),
84    (":mock", ":mock on|off — offline simulated answers"),
85    (":key", ":key [<api-key>] — API key status, or set one"),
86    (
87        ":save",
88        ":save <path> / :open <path> — session JSON, or a sketch if the path ends in .jev",
89    ),
90    (":clear", "clear the transcript (Ctrl-L)"),
91    (":quit", "leave (Ctrl-C)"),
92];
93
94pub struct App {
95    pub session: Session,
96    pub transcript: Vec<Line<'static>>,
97    pub input: String,
98    pub cursor: usize,
99    pub history: Vec<String>,
100    hist_idx: Option<usize>,
101    stash: String,
102    /// Lines scrolled back from the bottom; 0 follows the tail.
103    pub scroll: usize,
104    pub client: Option<Client>,
105    pub mock: bool,
106    pub pending: bool,
107    pub spinner: usize,
108    pub lesson: usize,
109    pub lesson_open: bool,
110    pub suggested: Option<String>,
111    pub threshold: f64,
112    pub timeout: Option<Duration>,
113    /// Dollars per million tokens, from `JEV_PRICE` or `:cost`; `None` counts tokens only.
114    pub rates: Option<cost::Rates>,
115    pub last_raw: Option<String>,
116    /// Some while builder mode is open.
117    pub builder: Option<Builder>,
118    /// Some while sketch mode is open.
119    pub sketch: Option<Editor>,
120    pub quit: bool,
121    /// The prefixes a live `:trend` is asking about, so the answers can be lined up when they land.
122    trend_steps: Vec<Session>,
123    tx: UnboundedSender<Msg>,
124}
125
126impl App {
127    pub fn new(tx: UnboundedSender<Msg>) -> Self {
128        let client = Client::from_env().ok();
129        let mock = client.is_none();
130        let mut app = Self {
131            session: Session::new(),
132            transcript: Vec::new(),
133            input: String::new(),
134            cursor: 0,
135            history: Vec::new(),
136            hist_idx: None,
137            stash: String::new(),
138            scroll: 0,
139            client,
140            mock,
141            pending: false,
142            spinner: 0,
143            lesson: 0,
144            lesson_open: false,
145            suggested: None,
146            threshold: 0.5,
147            timeout: None,
148            rates: cost::rates_from_env(),
149            last_raw: None,
150            builder: None,
151            sketch: None,
152            quit: false,
153            trend_steps: Vec::new(),
154            tx,
155        };
156        app.banner();
157        app
158    }
159
160    pub fn model_name(&self) -> String {
161        self.session
162            .model
163            .clone()
164            .or_else(|| self.client.as_ref().map(|c| c.default_model().to_owned()))
165            .unwrap_or_else(|| "jev-latest".to_owned())
166    }
167
168    // ---- transcript -------------------------------------------------------------------------
169
170    fn push(&mut self, line: Line<'static>) {
171        self.transcript.push(line);
172        self.scroll = 0;
173    }
174
175    fn extend(&mut self, lines: impl IntoIterator<Item = Line<'static>>) {
176        self.transcript.extend(lines);
177        self.scroll = 0;
178    }
179
180    fn blank(&mut self) {
181        match self.transcript.last() {
182            None => {}
183            Some(last) if last.spans.is_empty() => {}
184            _ => self.push(Line::default()),
185        }
186    }
187
188    fn note(&mut self, text: impl Into<String>) {
189        self.push(Line::from(dim(format!("  {}", text.into()))));
190    }
191
192    fn warn(&mut self, text: impl Into<String>) {
193        self.push(styled(format!("  {}", text.into()), WARN));
194    }
195
196    fn bad(&mut self, text: impl Into<String>) {
197        self.push(styled(format!("  {}", text.into()), BAD));
198    }
199
200    fn heading(&mut self, text: impl Into<String>) {
201        self.blank();
202        self.push(Line::from(Span::styled(
203            text.into(),
204            Style::new().fg(ACCENT).add_modifier(Modifier::BOLD),
205        )));
206    }
207
208    fn banner(&mut self) {
209        self.push(Line::from(vec![
210            Span::styled("jev", Style::new().fg(ACCENT).add_modifier(Modifier::BOLD)),
211            dim("  ·  a playground for TypeSafe System One questions"),
212        ]));
213        self.push(Line::from(dim(
214            "  state in, typed answers out: noul (probability of yes), choice (one of N), score (ordered levels)",
215        )));
216        self.blank();
217        if self.mock {
218            self.push(Line::from(vec![
219                Span::styled(
220                    "  MOCK MODE",
221                    Style::new().fg(WARN).add_modifier(Modifier::BOLD),
222                ),
223                dim("  no TYPESAFE_API_KEY, so answers are simulated locally."),
224            ]));
225            self.note("Everything else is real: the same questions, the same wire format. :key <api-key> to go live.");
226        } else {
227            self.note(format!("Live against {}.", self.model_name()));
228        }
229        self.blank();
230        self.note("Press Enter on an empty line to send. :help for commands, :lesson for the guided track, :sketch to write the whole request as a page.");
231        self.lesson_open = true;
232        self.suggested = Some(lessons::LESSONS[0].try_this.to_owned());
233    }
234
235    // ---- events -----------------------------------------------------------------------------
236
237    pub fn handle(&mut self, msg: Msg) {
238        match msg {
239            Msg::Term(Event::Key(key)) if key.kind != KeyEventKind::Release => self.key(key),
240            Msg::Term(_) => {}
241            Msg::Tick => self.spinner = self.spinner.wrapping_add(1),
242            Msg::Answered(result, elapsed) => {
243                self.pending = false;
244                match *result {
245                    Ok(res) => self.show_response(&res, elapsed),
246                    Err(e) => {
247                        self.blank();
248                        self.extend(error_lines(&e));
249                    }
250                }
251            }
252            Msg::Trend(result) => {
253                self.pending = false;
254                match *result {
255                    Ok(responses) => {
256                        let steps = std::mem::take(&mut self.trend_steps);
257                        let per_turn: Vec<Vec<headless::Answered>> = responses
258                            .iter()
259                            .enumerate()
260                            .map(|(i, response)| {
261                                headless::live_answers(
262                                    steps.get(i).unwrap_or(&self.session),
263                                    response,
264                                )
265                            })
266                            .collect();
267                        if let Some(last) = responses.last() {
268                            self.last_raw = serde_json::to_string_pretty(&last.raw).ok();
269                        }
270                        self.draw_trend(&steps, &per_turn, Some(&responses));
271                    }
272                    Err(e) => {
273                        self.blank();
274                        self.extend(error_lines(&e));
275                    }
276                }
277            }
278            Msg::Models(result) => {
279                self.pending = false;
280                match *result {
281                    Ok(res) => {
282                        self.heading("models");
283                        for m in &res.models {
284                            self.push(Line::from(vec![
285                                Span::raw("  "),
286                                bold(m.name.clone()),
287                                dim(format!("  {}  {}", m.release_date, m.description)),
288                            ]));
289                        }
290                    }
291                    Err(e) => {
292                        self.blank();
293                        self.extend(error_lines(&e));
294                    }
295                }
296            }
297        }
298    }
299
300    fn key(&mut self, key: KeyEvent) {
301        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
302        if matches!(key.code, KeyCode::Char('c')) && ctrl {
303            self.quit = true;
304            return;
305        }
306        if self.builder.is_some() {
307            self.builder_key(key);
308            return;
309        }
310        if self.sketch.is_some() {
311            self.sketch_key(key);
312            return;
313        }
314        if words::is_word_left(&key) {
315            let chars: Vec<char> = self.input.chars().collect();
316            self.cursor = words::word_left(&chars, self.cursor);
317            return;
318        }
319        if words::is_word_right(&key) {
320            let chars: Vec<char> = self.input.chars().collect();
321            self.cursor = words::word_right(&chars, self.cursor);
322            return;
323        }
324        if words::is_delete_word_left(&key) {
325            self.delete_word();
326            return;
327        }
328        match key.code {
329            KeyCode::Char('c' | 'd') if ctrl => self.quit = true,
330            KeyCode::Char('b') if ctrl => self.open_builder(""),
331            KeyCode::Char('k') if ctrl => self.open_sketch(),
332            KeyCode::Char('l') if ctrl => {
333                self.transcript.clear();
334                self.scroll = 0;
335            }
336            KeyCode::Char('t') if ctrl => self.load_suggestion(),
337            KeyCode::Char('n') if ctrl => self.exec(":lesson next"),
338            KeyCode::Char('a') if ctrl => self.cursor = 0,
339            KeyCode::Char('e') if ctrl => self.cursor = self.input.chars().count(),
340            KeyCode::Char('u') if ctrl => {
341                self.input.clear();
342                self.cursor = 0;
343            }
344            KeyCode::Char('w') if ctrl => self.delete_word(),
345            KeyCode::Char(c) if words::is_typed(&key) => self.insert(c),
346            KeyCode::Backspace => {
347                if self.cursor > 0 {
348                    self.cursor -= 1;
349                    self.remove_at(self.cursor);
350                }
351            }
352            KeyCode::Delete => self.remove_at(self.cursor),
353            KeyCode::Left => self.cursor = self.cursor.saturating_sub(1),
354            KeyCode::Right => self.cursor = (self.cursor + 1).min(self.input.chars().count()),
355            KeyCode::Home => self.cursor = 0,
356            KeyCode::End => self.cursor = self.input.chars().count(),
357            KeyCode::Up => self.recall(-1),
358            KeyCode::Down => self.recall(1),
359            KeyCode::PageUp => self.scroll = self.scroll.saturating_add(10),
360            KeyCode::PageDown => self.scroll = self.scroll.saturating_sub(10),
361            KeyCode::Esc => {
362                if self.scroll > 0 {
363                    self.scroll = 0;
364                } else {
365                    self.input.clear();
366                    self.cursor = 0;
367                }
368            }
369            KeyCode::Tab => self.complete(),
370            KeyCode::Enter => self.submit(),
371            _ => {}
372        }
373    }
374
375    fn insert(&mut self, c: char) {
376        let at = self.byte_at(self.cursor);
377        self.input.insert(at, c);
378        self.cursor += 1;
379    }
380
381    fn remove_at(&mut self, index: usize) {
382        if index < self.input.chars().count() {
383            let at = self.byte_at(index);
384            self.input.remove(at);
385        }
386    }
387
388    fn delete_word(&mut self) {
389        let mut i = self.cursor;
390        let chars: Vec<char> = self.input.chars().collect();
391        while i > 0 && chars[i - 1].is_whitespace() {
392            i -= 1;
393        }
394        while i > 0 && !chars[i - 1].is_whitespace() {
395            i -= 1;
396        }
397        let keep: String = chars[..i]
398            .iter()
399            .chain(chars[self.cursor..].iter())
400            .collect();
401        self.input = keep;
402        self.cursor = i;
403    }
404
405    fn byte_at(&self, char_index: usize) -> usize {
406        self.input
407            .char_indices()
408            .nth(char_index)
409            .map(|(i, _)| i)
410            .unwrap_or(self.input.len())
411    }
412
413    fn recall(&mut self, delta: isize) {
414        if self.history.is_empty() {
415            return;
416        }
417        let next = match (self.hist_idx, delta) {
418            (None, -1) => {
419                self.stash = std::mem::take(&mut self.input);
420                Some(self.history.len() - 1)
421            }
422            (Some(0), -1) => Some(0),
423            (Some(i), -1) => Some(i - 1),
424            (Some(i), _) if i + 1 < self.history.len() => Some(i + 1),
425            (Some(_), _) => None,
426            (None, _) => None,
427        };
428        self.hist_idx = next;
429        self.input = match next {
430            Some(i) => self.history[i].clone(),
431            None => std::mem::take(&mut self.stash),
432        };
433        self.cursor = self.input.chars().count();
434    }
435
436    fn complete(&mut self) {
437        let word = self.input.trim_start();
438        if !word.starts_with(':') || word.contains(' ') {
439            return;
440        }
441        let matches: Vec<&str> = COMMANDS
442            .iter()
443            .map(|(c, _)| *c)
444            .filter(|c| c.starts_with(word))
445            .collect();
446        match matches.as_slice() {
447            [only] => {
448                self.input = format!("{only} ");
449                self.cursor = self.input.chars().count();
450            }
451            [] => {}
452            many => {
453                let list = many.join("  ");
454                self.note(list);
455            }
456        }
457    }
458
459    fn load_suggestion(&mut self) {
460        if let Some(s) = self.suggested.clone() {
461            self.input = s;
462            self.cursor = self.input.chars().count();
463        }
464    }
465
466    fn submit(&mut self) {
467        let line = self.input.trim().to_owned();
468        self.input.clear();
469        self.cursor = 0;
470        self.hist_idx = None;
471        if line.is_empty() {
472            self.ask();
473            return;
474        }
475        // An API key typed at the prompt is neither echoed nor kept in the history.
476        let secret = line.starts_with(":key ");
477        if !secret && self.history.last().map(String::as_str) != Some(line.as_str()) {
478            self.history.push(line.clone());
479        }
480        self.blank();
481        self.push(Line::from(vec![
482            Span::styled("› ", Style::new().fg(ACCENT)),
483            Span::raw(if secret {
484                ":key ••••••••".to_owned()
485            } else {
486                line.clone()
487            }),
488        ]));
489        self.exec(&line);
490    }
491
492    // ---- commands ---------------------------------------------------------------------------
493
494    pub fn exec(&mut self, line: &str) {
495        let line = line.trim();
496        if line.is_empty() {
497            return;
498        }
499        if !line.starts_with(':') {
500            // Bare text is the most common thing to want: it becomes the state.
501            self.set_state(Value::String(line.to_owned()));
502            return;
503        }
504        let (cmd, args) = match line.split_once(char::is_whitespace) {
505            Some((c, a)) => (c, a.trim()),
506            None => (line, ""),
507        };
508        match cmd {
509            ":help" | ":h" | ":?" => self.help(args),
510            ":quit" | ":q" | ":exit" => self.quit = true,
511            ":clear" => {
512                self.transcript.clear();
513                self.scroll = 0;
514            }
515            ":lesson" | ":l" => self.lesson(args),
516            ":try" => self.load_suggestion(),
517            ":preset" => self.preset(args),
518            ":state" | ":s" => self.state_cmd(args),
519            ":turn" => self.turn_cmd(args),
520            ":trend" => self.trend(),
521            ":noul" => self.add(session::parse_noul(args)),
522            ":choice" => self.add(session::parse_choice(args)),
523            ":score" => self.add(session::parse_score(args)),
524            ":raw" => self.add(session::parse_raw(args)),
525            ":build" | ":b" => self.open_builder(args),
526            ":sketch" | ":page" => match args {
527                "show" | "print" => self.show_sketch(),
528                _ => self.open_sketch(),
529            },
530            ":questions" | ":qs" => self.list_questions(),
531            ":rm" | ":drop" => {
532                if self.session.remove(args) {
533                    self.note(format!("dropped {args}"));
534                } else {
535                    self.warn(format!("no question named {args:?}"));
536                }
537            }
538            ":reset" => {
539                self.session = Session::new();
540                self.note("session emptied: no state, no questions");
541            }
542            ":ask" | ":send" => self.ask(),
543            ":json" => self.show_request(),
544            ":last" => self.show_last(),
545            ":cost" | ":price" => self.cost_cmd(args),
546            ":rust" => self.show_rust(),
547            ":threshold" => self.threshold_cmd(args),
548            ":model" => self.model_cmd(args),
549            ":models" => self.models_cmd(),
550            ":timeout" => self.timeout_cmd(args),
551            ":mock" => self.mock_cmd(args),
552            ":key" => self.key_cmd(args),
553            ":save" => self.save(args),
554            ":open" | ":load" => self.open(args),
555            other => {
556                self.warn(format!("unknown command {other}. :help lists them all."));
557            }
558        }
559    }
560
561    fn help(&mut self, topic: &str) {
562        if topic.starts_with("concept") {
563            self.heading("what jev answers");
564            for (title, body) in [
565                (
566                    "state",
567                    "The thing being judged: a string, or any JSON — a ticket, a draft reply, a diff, a row.",
568                ),
569                (
570                    "noul",
571                    "A yes/no question answered with a probability from 0 to 1. You choose the threshold; the model never does.",
572                ),
573                (
574                    "choice",
575                    "One label out of a set you define, with the probability of every label and a confidence over the spread.",
576                ),
577                (
578                    "score",
579                    "Ordered levels you define. The answer is probability-weighted, so 1.4 sits between level 1 and 2.",
580                ),
581                (
582                    "criteria",
583                    "The descriptions attached to a question — what a yes means, what each option or level means. Vague criteria are what low confidence usually means.",
584                ),
585                (
586                    "confidence",
587                    "How concentrated the distribution is. Gate automation on it and send the rest to a human.",
588                ),
589                (
590                    "conversation",
591                    "A state that is a list of turns instead of one message. The questions stay fixed and the thread grows, so the same rubric can be re-read after every reply. `:turn` builds one.",
592                ),
593                (
594                    "names",
595                    "Questions are a name → question map, and answers come back under the same names. Keep names stable across versions.",
596                ),
597            ] {
598                self.push(Line::from(vec![
599                    Span::raw("  "),
600                    Span::styled(
601                        format!("{title:12}"),
602                        Style::new().fg(ACCENT).add_modifier(Modifier::BOLD),
603                    ),
604                    Span::raw(body),
605                ]));
606            }
607            return;
608        }
609        self.heading("commands");
610        for (cmd, about) in COMMANDS {
611            self.push(Line::from(vec![
612                Span::raw("  "),
613                Span::styled(format!("{cmd:11}"), Style::new().fg(ACCENT)),
614                Span::raw(" "),
615                Span::raw(*about),
616            ]));
617        }
618        self.blank();
619        self.note("Bare text with no leading colon sets the state. Enter on an empty line sends.");
620        self.note("Keys: Ctrl-T try the lesson's command · Ctrl-N next lesson · Ctrl-K sketch · PgUp/PgDn scroll · Ctrl-L clear · Ctrl-C quit");
621        self.note(":help concepts explains noul, choice, score and confidence.");
622    }
623
624    fn lesson(&mut self, args: &str) {
625        let total = lessons::LESSONS.len();
626        match args {
627            "list" => {
628                self.heading("lessons");
629                for (i, l) in lessons::LESSONS.iter().enumerate() {
630                    let marker = if i == self.lesson { "▸" } else { " " };
631                    self.push(Line::from(vec![
632                        Span::raw(format!("  {marker} ")),
633                        dim(format!("{:>2}. ", i + 1)),
634                        Span::raw(l.title),
635                    ]));
636                }
637                self.note("`:lesson 3` jumps to one.");
638                return;
639            }
640            "next" => {
641                if self.lesson_open {
642                    self.lesson = (self.lesson + 1).min(total - 1);
643                }
644            }
645            "prev" | "back" => self.lesson = self.lesson.saturating_sub(1),
646            "" => {}
647            n => match n.parse::<usize>() {
648                Ok(n) if (1..=total).contains(&n) => self.lesson = n - 1,
649                _ => {
650                    self.warn(format!("lessons run 1 to {total}; try `:lesson list`."));
651                    return;
652                }
653            },
654        }
655        self.lesson_open = true;
656        let l = &lessons::LESSONS[self.lesson];
657        self.heading(format!(
658            "lesson {}/{}  ·  {}",
659            self.lesson + 1,
660            total,
661            l.title
662        ));
663        for para in l.body {
664            self.push(plain(format!("  {para}")));
665            self.push(Line::default());
666        }
667        let try_this = l.try_this.to_owned();
668        self.push(Line::from(vec![
669            Span::raw("  "),
670            Span::styled("try ", Style::new().fg(SCORE)),
671            Span::styled(
672                try_this.clone(),
673                Style::new().fg(SCORE).add_modifier(Modifier::BOLD),
674            ),
675        ]));
676        self.note("Ctrl-T puts that in the input line · Ctrl-N for the next lesson");
677        self.suggested = Some(try_this);
678    }
679
680    fn preset(&mut self, args: &str) {
681        if args.is_empty() || args == "list" {
682            self.heading("presets");
683            for p in presets::PRESETS {
684                self.push(Line::from(vec![
685                    Span::raw("  "),
686                    Span::styled(format!("{:10}", p.name), Style::new().fg(ACCENT)),
687                    Span::raw(p.about),
688                ]));
689            }
690            self.note("`:preset triage` loads one; `:questions` then shows what it built.");
691            return;
692        }
693        let Some(preset) = presets::find(args) else {
694            self.warn(format!("no preset {args:?}; `:preset list` has them."));
695            return;
696        };
697        self.session = Session::new();
698        for line in preset.script {
699            self.exec(line);
700        }
701        self.heading(format!("preset {}", preset.name));
702        self.note(preset.about);
703        self.note("`:ask` to send it, `:json` to see the body, `:questions` to review.");
704    }
705
706    fn state_cmd(&mut self, args: &str) {
707        match args {
708            "" => {
709                if self.session.turns().is_some() {
710                    self.show_turns();
711                    return;
712                }
713                self.heading("state");
714                if self.session.state_is_empty() {
715                    self.note("empty — type any text (no colon) or `:state <text>` to set it.");
716                } else {
717                    let pretty = serde_json::to_string_pretty(&self.session.state)
718                        .unwrap_or_else(|_| self.session.state_preview());
719                    self.extend(highlight::json(&pretty));
720                }
721            }
722            "clear" => {
723                self.session.state = Value::String(String::new());
724                self.note("state cleared");
725            }
726            _ => match args.split_once(char::is_whitespace) {
727                Some(("json", rest)) => match serde_json::from_str::<Value>(rest.trim()) {
728                    Ok(v) => self.set_state(v),
729                    Err(e) => self.bad(format!("not valid JSON: {e}")),
730                },
731                _ => self.set_state(Value::String(args.to_owned())),
732            },
733        }
734    }
735
736    /// `:turn` — the state as a conversation.
737    ///
738    /// Nothing new goes on the wire: the state becomes a list of turns and grows by one each time,
739    /// so the questions stay exactly as they were and Enter re-reads the whole thread. Watching a
740    /// noul move across the turns is the thing this is for.
741    fn turn_cmd(&mut self, args: &str) {
742        let trimmed = args.trim();
743        if trimmed.is_empty() || trimmed == "list" {
744            self.show_turns();
745            return;
746        }
747        if trimmed == "drop" || trimmed == "pop" {
748            match self.session.drop_turn() {
749                Some(dropped) => {
750                    self.note(format!("dropped {}", session::turn_text(&dropped)));
751                    if self.session.state_is_empty() {
752                        self.note("that was the last one; the state is empty.");
753                    }
754                }
755                None => self.warn("no turns to drop — the state is not a conversation yet."),
756            }
757            return;
758        }
759        let turn = match session::parse_turn(trimmed) {
760            Ok(turn) => turn,
761            Err(e) => {
762                self.bad(e);
763                return;
764            }
765        };
766        // Before it is added, so the note below can say what became of the text that was there.
767        let seeded = !self.session.state_is_empty() && self.session.turns().is_none();
768        let turns = match self.session.add_turn(turn.clone()) {
769            Ok(turns) => turns,
770            Err(e) => {
771                self.bad(e);
772                self.note("`:state clear` starts one from nothing.");
773                return;
774            }
775        };
776        if seeded {
777            self.note("the state you had became the first turn.");
778        }
779        self.extend(turn_lines(turns.len() - 1, &turn));
780        if turn.who.is_none() {
781            self.note("nobody named — `:turn customer: …` attributes it.");
782        }
783        if turns.len() == 1 {
784            self.note(
785                "add the reply with another :turn; Enter re-asks every question over the thread.",
786            );
787        }
788    }
789
790    fn show_turns(&mut self) {
791        let Some(turns) = self.session.turns() else {
792            self.heading("state");
793            if self.session.state_is_empty() {
794                self.note("empty — `:turn customer: <text>` starts a conversation.");
795            } else {
796                let pretty = serde_json::to_string_pretty(&self.session.state)
797                    .unwrap_or_else(|_| self.session.state_preview());
798                self.extend(highlight::json(&pretty));
799                self.note(
800                    "not a conversation — `:turn <who>: <text>` makes this text the first turn.",
801                );
802            }
803            return;
804        };
805        let plural = if turns.len() == 1 { "" } else { "s" };
806        self.heading(format!("conversation ({} turn{plural})", turns.len()));
807        for (i, turn) in turns.iter().enumerate() {
808            self.extend(turn_lines(i, turn));
809        }
810        self.note("`:turn drop` takes the last one back; `:json` shows it as the state it is.");
811    }
812
813    fn set_state(&mut self, value: Value) {
814        self.session.state = value;
815        let preview = self.session.state_preview();
816        let shown = if preview.chars().count() > 120 {
817            format!("{}…", preview.chars().take(120).collect::<String>())
818        } else {
819            preview
820        };
821        self.note(format!("state ← {shown}"));
822        if self.session.questions.is_empty() {
823            self.note(
824                "now add a question: :noul, :choice or :score (`:preset triage` loads a set).",
825            );
826        }
827    }
828
829    fn add(&mut self, parsed: Result<(String, Question), String>) {
830        match parsed {
831            Ok((name, question)) => {
832                let replaced = self.session.insert(name.clone(), question.clone());
833                let index = self
834                    .session
835                    .questions
836                    .iter()
837                    .position(|(n, _)| *n == name)
838                    .unwrap_or(0);
839                self.extend(question_lines(index, &name, &question));
840                if replaced {
841                    self.note(format!("replaced {name}"));
842                }
843                if self.session.questions.len() == 1 {
844                    self.note("Enter on an empty line sends the session.");
845                }
846            }
847            Err(e) => self.bad(e),
848        }
849    }
850
851    fn list_questions(&mut self) {
852        self.heading(format!("questions ({})", self.session.questions.len()));
853        if self.session.questions.is_empty() {
854            self.note("none yet — :noul, :choice, :score, or :preset triage");
855            return;
856        }
857        let items: Vec<(usize, String, Question)> = self
858            .session
859            .questions
860            .iter()
861            .enumerate()
862            .map(|(i, (n, q))| (i, n.clone(), q.clone()))
863            .collect();
864        for (i, name, q) in items {
865            self.extend(question_lines(i, &name, &q));
866        }
867    }
868
869    /// The names the session already holds, so the builder can say what it would overwrite.
870    fn question_names(&self) -> Vec<String> {
871        self.session
872            .questions
873            .iter()
874            .map(|(name, _)| name.clone())
875            .collect()
876    }
877
878    /// Builder mode: the same question, built in a form, with the JSON shown as it is typed.
879    fn open_builder(&mut self, name: &str) {
880        let state = match &self.session.state {
881            Value::String(s) => s.clone(),
882            Value::Null => String::new(),
883            other => other.to_string(),
884        };
885        let existing = self.question_names();
886        self.builder = Some(Builder::new(state, name.trim()).over(existing));
887        self.note("builder mode — Tab moves, Ctrl-S adds the question, Esc closes.");
888    }
889
890    fn builder_key(&mut self, key: KeyEvent) {
891        let Some(builder) = self.builder.as_mut() else {
892            return;
893        };
894        match builder.key(key) {
895            Outcome::Open => {}
896            Outcome::Cancel => {
897                self.builder = None;
898                self.note("builder closed");
899            }
900            Outcome::Commit(name, question, state) => {
901                let command = builder.as_command();
902                let kind = builder.kind;
903                if !state.trim().is_empty() && Value::String(state.clone()) != self.session.state {
904                    self.session.state = Value::String(state.clone());
905                }
906                self.blank();
907                self.push(Line::from(vec![
908                    Span::styled("› ", Style::new().fg(ACCENT)),
909                    Span::raw(command),
910                ]));
911                self.note("(what builder mode just built — the one-line form does the same thing)");
912                self.add(Ok((name, *question)));
913                // Stay in the form, on the same type: a rubric is usually several questions of one
914                // shape, and re-picking `choice` for every one of them is what made the form
915                // slower than typing the command.
916                let existing = self.question_names();
917                self.builder = Some(Builder::new(state, "").of_kind(kind).over(existing));
918                self.note(format!(
919                    "still in the builder, type still `{}` — name the next one, or Esc to close.",
920                    kind.label()
921                ));
922            }
923        }
924    }
925
926    /// Sketch mode: the whole session on one page, parsed as it is typed.
927    fn open_sketch(&mut self) {
928        let mut editor = Editor::new(&sketch::render(&self.session));
929        if self.session.state_is_empty() && self.session.questions.is_empty() {
930            editor.preview = editor::Preview::Answers;
931        }
932        self.sketch = Some(editor);
933        self.note("sketch mode — write the state, a --- line, then questions. ^S applies, ^G applies and sends, Esc closes.");
934    }
935
936    fn sketch_key(&mut self, key: KeyEvent) {
937        let Some(editor) = self.sketch.as_mut() else {
938            return;
939        };
940        let outcome = editor.key(key);
941        let send = match outcome {
942            editor::Outcome::Open => return,
943            editor::Outcome::Cancel => {
944                self.sketch = None;
945                self.note("sketch closed, session unchanged");
946                return;
947            }
948            editor::Outcome::Apply => false,
949            editor::Outcome::ApplyAndAsk => true,
950        };
951        let parsed = editor.parsed();
952        if !parsed.ok() {
953            let n = parsed.problems.len();
954            let first = &parsed.problems[0];
955            editor.row = first.line.min(editor.lines.len() - 1);
956            editor.col = 0;
957            editor.message = Some(format!(
958                "{n} problem{} to fix first — line {}: {}",
959                if n == 1 { "" } else { "s" },
960                first.line + 1,
961                first.message
962            ));
963            return;
964        }
965        let text = editor.text();
966        self.sketch = None;
967        self.apply_sketch(&parsed, &text);
968        if send {
969            self.ask();
970        }
971    }
972
973    /// Replace the session with a parsed page and say what changed.
974    fn apply_sketch(&mut self, parsed: &sketch::Parsed, text: &str) {
975        // The bars are on the page but not on the wire, so they count as a change of their own.
976        let snapshot = |app: &Self| {
977            format!(
978                "{}{:?}",
979                app.session.request_json(&app.model_name()),
980                app.session.bars
981            )
982        };
983        let before = snapshot(self);
984        // The page is the whole request: no `@model` line means the client default.
985        self.session = parsed.to_session();
986        let after = snapshot(self);
987        self.blank();
988        self.push(Line::from(vec![
989            Span::styled("› ", Style::new().fg(ACCENT)),
990            dim("sketch applied"),
991        ]));
992        if before == after {
993            self.note("nothing changed.");
994            return;
995        }
996        self.extend(sketch::highlight(text.trim_end()));
997        let n = self.session.questions.len();
998        self.note(format!(
999            "session ← {n} question{} from the page. Enter sends it; :json shows the body.",
1000            if n == 1 { "" } else { "s" }
1001        ));
1002    }
1003
1004    fn show_sketch(&mut self) {
1005        self.heading("this session, as a sketch");
1006        let text = sketch::render(&self.session);
1007        self.extend(sketch::highlight(text.trim_end()));
1008        self.note("`name?` asks yes/no · `label = why` lines make a choice · `low < high` makes a score · :sketch opens it for editing.");
1009    }
1010
1011    fn show_request(&mut self) {
1012        let model = self.model_name();
1013        let body = self.session.request_json(&model);
1014        self.heading("POST /v1/systemone");
1015        self.extend(highlight::json(&body));
1016        self.note("Questions are a name → {type, instructions, criteria} map; answers come back under the same names.");
1017    }
1018
1019    fn show_last(&mut self) {
1020        match self.last_raw.clone() {
1021            Some(raw) => {
1022                self.heading("last response body");
1023                self.extend(highlight::json(&raw));
1024            }
1025            None => self.note("nothing sent yet."),
1026        }
1027    }
1028
1029    /// `:cost` estimates the next call; `:cost <in>/<out>` puts a price on it, `:cost off` drops it.
1030    fn cost_cmd(&mut self, args: &str) {
1031        match args {
1032            "off" | "clear" | "none" => {
1033                self.rates = None;
1034                self.note("rates cleared — :cost now counts tokens only.");
1035                return;
1036            }
1037            "" => {}
1038            _ => match cost::parse_rates(args) {
1039                Ok(rates) => {
1040                    self.rates = Some(rates);
1041                    self.note(format!("rates ← {}", cost::format_rates(rates)));
1042                }
1043                Err(message) => {
1044                    self.bad(message);
1045                    return;
1046                }
1047            },
1048        }
1049        if self.session.questions.is_empty() {
1050            self.warn(
1051                "nothing to price yet — :noul, :choice or :score first (`:preset triage` loads a set).",
1052            );
1053            return;
1054        }
1055        let model = self.model_name();
1056        let estimate = cost::estimate(&self.session, &model);
1057        let thread = cost::thread(&self.session, &model);
1058        let rates = self.rates;
1059        self.heading(format!("cost estimate  ·  {model}"));
1060        self.extend(cost_lines(
1061            &estimate,
1062            rates,
1063            ":cost 0.20/1.00 prices it: dollars per million tokens, input then output",
1064            thread.as_ref(),
1065        ));
1066        self.note(
1067            "Tokens are estimated from the body, not counted by the API's tokenizer; `usage` on a live answer is the real thing.",
1068        );
1069        self.note(
1070            "Answer sizes come from the shapes you asked for: a score echoes its legend, a choice one probability per label.",
1071        );
1072        if let Some(rates) = rates {
1073            self.note(format!(
1074                "{}={} sets the same rates at startup.",
1075                cost::PRICE_ENV,
1076                cost::rates_value(rates)
1077            ));
1078        }
1079    }
1080
1081    fn show_rust(&mut self) {
1082        let model = self.model_name();
1083        let code = codegen::rust(&self.session, &model, self.threshold);
1084        self.heading("this session, as Rust");
1085        self.extend(highlight::rust(&code));
1086    }
1087
1088    fn threshold_cmd(&mut self, args: &str) {
1089        if args.is_empty() {
1090            self.note(format!("threshold {:.2}", self.threshold));
1091            return;
1092        }
1093        match args.parse::<f64>() {
1094            Ok(t) if (0.0..=1.0).contains(&t) => {
1095                self.threshold = t;
1096                self.note(format!(
1097                    "threshold {t:.2} — a noul now reads as yes at {t:.2} or above (that is `answer.is_yes({t:.2})`)"
1098                ));
1099            }
1100            _ => self.bad("threshold takes a number from 0 to 1, e.g. :threshold 0.8"),
1101        }
1102    }
1103
1104    fn model_cmd(&mut self, args: &str) {
1105        if args.is_empty() {
1106            let model = self.model_name();
1107            self.note(format!("model {model}"));
1108            self.note("`jev-latest` moves with releases; pin a version for reproducibility.");
1109            return;
1110        }
1111        self.session.model = Some(args.to_owned());
1112        self.note(format!("model ← {args}"));
1113    }
1114
1115    fn models_cmd(&mut self) {
1116        if self.mock || self.client.is_none() {
1117            self.heading("models (mock)");
1118            for m in mock::models() {
1119                self.push(Line::from(vec![
1120                    Span::raw("  "),
1121                    bold(m.name.clone()),
1122                    dim(format!("  {}  {}", m.release_date, m.description)),
1123                ]));
1124            }
1125            self.note("simulated — :key <api-key> to list the real ones.");
1126            return;
1127        }
1128        let client = self.client.clone().expect("checked");
1129        let tx = self.tx.clone();
1130        self.pending = true;
1131        self.note("GET /v1/models …");
1132        tokio::spawn(async move {
1133            let res = client.models().list().await;
1134            let _ = tx.send(Msg::Models(Box::new(res)));
1135        });
1136    }
1137
1138    fn timeout_cmd(&mut self, args: &str) {
1139        if args.is_empty() {
1140            match self.timeout {
1141                Some(t) => self.note(format!("timeout {:.1}s per attempt", t.as_secs_f64())),
1142                None => self.note("timeout 10s per attempt (the SDK default)"),
1143            }
1144            return;
1145        }
1146        match args.parse::<f64>() {
1147            Ok(s) if s > 0.0 => {
1148                self.timeout = Some(Duration::from_secs_f64(s));
1149                self.note(format!(
1150                    "timeout ← {s:.1}s per attempt; retries still get their own attempts within a 30s budget"
1151                ));
1152            }
1153            _ => self.bad("timeout takes seconds, e.g. :timeout 3"),
1154        }
1155    }
1156
1157    fn mock_cmd(&mut self, args: &str) {
1158        match args {
1159            "on" => self.mock = true,
1160            "off" => {
1161                if self.client.is_none() {
1162                    self.warn("no API key, so mock mode stays on. :key <api-key> to go live.");
1163                    return;
1164                }
1165                self.mock = false;
1166            }
1167            "" => {}
1168            _ => {
1169                self.bad("`:mock on` or `:mock off`");
1170                return;
1171            }
1172        }
1173        if self.mock {
1174            self.note(
1175                "mock on — answers are simulated locally, deterministic per state and question.",
1176            );
1177        } else {
1178            self.note(format!("mock off — live against {}.", self.model_name()));
1179        }
1180    }
1181
1182    fn key_cmd(&mut self, args: &str) {
1183        if args.is_empty() {
1184            match std::env::var("TYPESAFE_API_KEY") {
1185                Ok(k) if !k.trim().is_empty() => {
1186                    self.note(format!("TYPESAFE_API_KEY is set ({}).", masked(&k)))
1187                }
1188                _ => self.note(
1189                    "TYPESAFE_API_KEY is not set — :key <api-key> sets one for this session.",
1190                ),
1191            }
1192            if self.client.is_some() {
1193                let model = self.model_name();
1194                self.note(format!("client ready, default model {model}"));
1195            }
1196            return;
1197        }
1198        match Client::builder().api_key(args).build() {
1199            Ok(client) => {
1200                let masked = masked(args);
1201                self.client = Some(client);
1202                self.mock = false;
1203                self.note(format!(
1204                    "key accepted ({masked}); mock off, calls go to the API now."
1205                ));
1206            }
1207            Err(e) => self.extend(error_lines(&e)),
1208        }
1209    }
1210
1211    fn save(&mut self, path: &str) {
1212        if path.is_empty() {
1213            self.bad(":save <path>");
1214            return;
1215        }
1216        let body = if path.ends_with(".jev") {
1217            sketch::render(&self.session)
1218        } else {
1219            self.session.request_json(&self.model_name())
1220        };
1221        match std::fs::write(path, &body) {
1222            Ok(()) => self.note(format!("wrote {path}")),
1223            Err(e) => self.bad(format!("could not write {path}: {e}")),
1224        }
1225    }
1226
1227    fn open(&mut self, path: &str) {
1228        if path.is_empty() {
1229            self.bad(":open <path>");
1230            return;
1231        }
1232        let text = match std::fs::read_to_string(path) {
1233            Ok(t) => t,
1234            Err(e) => {
1235                self.bad(format!("could not read {path}: {e}"));
1236                return;
1237            }
1238        };
1239        if path.ends_with(".jev") {
1240            let parsed = sketch::parse(&text);
1241            if let Some(p) = parsed.problems.first() {
1242                self.bad(format!("{path}:{}: {}", p.line + 1, p.message));
1243                return;
1244            }
1245            self.session = parsed.to_session();
1246            self.note(format!("loaded {path}"));
1247            self.list_questions();
1248            return;
1249        }
1250        match session::from_body(&text) {
1251            Ok(session) => {
1252                self.session = session;
1253                self.note(format!("loaded {path}"));
1254                self.list_questions();
1255            }
1256            Err(e) => self.bad(e),
1257        }
1258    }
1259
1260    // ---- asking -----------------------------------------------------------------------------
1261
1262    fn ask(&mut self) {
1263        if self.pending {
1264            self.warn("a request is already in flight.");
1265            return;
1266        }
1267        if self.session.questions.is_empty() {
1268            self.warn(
1269                "no questions yet — :noul, :choice or :score first (`:preset triage` loads a set).",
1270            );
1271            return;
1272        }
1273        if self.session.state_is_empty() {
1274            self.warn("no state yet — type the text to judge, or `:state <text>`.");
1275            return;
1276        }
1277        if self.mock || self.client.is_none() {
1278            self.ask_mock();
1279            return;
1280        }
1281        let client = self.client.clone().expect("checked");
1282        let state = self.session.state.clone();
1283        let questions = self.session.to_questions();
1284        let model = self.model_name();
1285        let timeout = self.timeout;
1286        let tx = self.tx.clone();
1287        self.pending = true;
1288        self.blank();
1289        self.push(Line::from(vec![
1290            dim("  POST /v1/systemone  "),
1291            dim(model.clone()),
1292            dim(format!("  {} question(s)", self.session.questions.len())),
1293        ]));
1294        tokio::spawn(async move {
1295            let started = std::time::Instant::now();
1296            let mut req = client.system_one(state, questions).model(model);
1297            if let Some(t) = timeout {
1298                req = req.timeout(t);
1299            }
1300            let res = req.await;
1301            let _ = tx.send(Msg::Answered(Box::new(res), started.elapsed()));
1302        });
1303    }
1304
1305    /// `:trend` — every question asked again after each turn of the conversation, drawn as one line
1306    /// per question: a spark across the turns, where it started and ended, and the turns it changed
1307    /// its mind. A thread of n turns is n calls, so the cost line counts all of them.
1308    pub fn trend(&mut self) {
1309        if self.pending {
1310            self.warn("a request is already in flight.");
1311            return;
1312        }
1313        if self.session.questions.is_empty() {
1314            self.warn(
1315                "no questions yet — :noul, :choice or :score first (`:preset triage` loads a set).",
1316            );
1317            return;
1318        }
1319        let steps = trend::prefixes(&self.session);
1320        if steps.is_empty() {
1321            self.warn(
1322                ":trend needs a conversation — `:turn <who>: <text>` builds one, a turn at a time.",
1323            );
1324            return;
1325        }
1326        if self.mock || self.client.is_none() {
1327            let per_turn: Vec<Vec<headless::Answered>> =
1328                steps.iter().map(headless::mock_answers).collect();
1329            self.draw_trend(&steps, &per_turn, None);
1330            return;
1331        }
1332        let client = self.client.clone().expect("checked");
1333        let model = self.model_name();
1334        let timeout = self.timeout;
1335        let tx = self.tx.clone();
1336        let asked: Vec<(Value, typesafe::Questions)> = steps
1337            .iter()
1338            .map(|step| (step.state.clone(), step.to_questions()))
1339            .collect();
1340        self.pending = true;
1341        self.trend_steps = steps;
1342        self.blank();
1343        let n = asked.len();
1344        let plural = if n == 1 { "" } else { "s" };
1345        self.note(format!(
1346            "asking after each of {n} turn{plural}: {n} call{plural}"
1347        ));
1348        // One after another: each prefix is its own call, and the thread is short.
1349        tokio::spawn(async move {
1350            let mut responses = Vec::with_capacity(asked.len());
1351            for (state, questions) in asked {
1352                let mut req = client.system_one(state, questions).model(model.clone());
1353                if let Some(t) = timeout {
1354                    req = req.timeout(t);
1355                }
1356                match req.await {
1357                    Ok(response) => responses.push(response),
1358                    Err(e) => {
1359                        let _ = tx.send(Msg::Trend(Box::new(Err(e))));
1360                        return;
1361                    }
1362                }
1363            }
1364            let _ = tx.send(Msg::Trend(Box::new(Ok(responses))));
1365        });
1366    }
1367
1368    fn draw_trend(
1369        &mut self,
1370        steps: &[Session],
1371        per_turn: &[Vec<headless::Answered>],
1372        responses: Option<&[SystemOneResponse]>,
1373    ) {
1374        let n = steps.len();
1375        let plural = if n == 1 { "" } else { "s" };
1376        self.heading(format!("trend · {n} turn{plural}"));
1377        self.extend(trend::trend_lines(&trend::series(
1378            &self.session,
1379            per_turn,
1380            self.threshold,
1381        )));
1382        let model = self.model_name();
1383        let calls = format!("over {n} call{plural}");
1384        let counted = responses.is_some_and(|all| {
1385            all.iter()
1386                .all(|r| r.usage.input_tokens.is_some() && r.usage.output_tokens.is_some())
1387        });
1388        let (mut input, mut output) = (0u64, 0u64);
1389        match responses {
1390            Some(all) if counted => {
1391                for r in all {
1392                    input += r.usage.input_tokens.unwrap_or(0);
1393                    output += r.usage.output_tokens.unwrap_or(0);
1394                }
1395            }
1396            _ => {
1397                for step in steps {
1398                    let estimate = cost::estimate(step, &model);
1399                    input += estimate.input_tokens as u64;
1400                    output += estimate.output_tokens as u64;
1401                }
1402            }
1403        }
1404        let money = match self.rates {
1405            Some(rates) => format!(" · {}", cost::usd(cost::price(input, output, rates).total)),
1406            None => String::new(),
1407        };
1408        let tokens = format!("{input} in / {output} out tokens{money} {calls}");
1409        if counted {
1410            self.note(tokens);
1411        } else if responses.is_none() {
1412            self.note(format!("≈ {tokens} — estimated, since nothing was sent."));
1413        } else {
1414            self.note(format!("≈ {tokens} — estimated, nothing was counted."));
1415        }
1416    }
1417
1418    fn ask_mock(&mut self) {
1419        let state = self.session.state.clone();
1420        let answers: Vec<(String, Option<Answer>)> = self
1421            .session
1422            .questions
1423            .iter()
1424            .map(|(name, q)| {
1425                let json = serde_json::to_value(q).unwrap_or(Value::Null);
1426                (name.clone(), mock::answer(&state, name, &json))
1427            })
1428            .collect();
1429
1430        self.blank();
1431        self.push(Line::from(vec![
1432            Span::styled(
1433                "  answers  ",
1434                Style::new().fg(WARN).add_modifier(Modifier::BOLD),
1435            ),
1436            dim(format!("simulated · {}", self.model_name())),
1437        ]));
1438        for (name, answer) in &answers {
1439            let threshold = self.session.threshold_of(name, self.threshold);
1440            match answer {
1441                Some(a) => self.extend(answer_lines(name, a, threshold)),
1442                None => self.warn(format!(
1443                    "{name}: mock mode cannot simulate this question shape."
1444                )),
1445            }
1446        }
1447        self.last_raw = Some(mock::body(&answers, &self.model_name()));
1448        let estimate = cost::estimate(&self.session, &self.model_name());
1449        let money = match self.rates {
1450            Some(rates) => format!(
1451                " · {}",
1452                cost::usd(cost::price_estimate(&estimate, rates).total)
1453            ),
1454            None => String::new(),
1455        };
1456        self.note(format!(
1457            "≈ {} in / {} out tokens{money} — estimated, since nothing was sent (:cost breaks it down).",
1458            estimate.input_tokens, estimate.output_tokens
1459        ));
1460        self.note(
1461            "Mock numbers are deterministic noise, not judgement. :key <api-key> for real answers.",
1462        );
1463    }
1464
1465    fn show_response(&mut self, res: &SystemOneResponse, elapsed: Duration) {
1466        let money = match self
1467            .rates
1468            .and_then(|rates| cost::price_usage(&res.usage, rates))
1469        {
1470            Some(cost) => format!(" · {}", cost::usd(cost.total)),
1471            None => String::new(),
1472        };
1473        self.blank();
1474        self.push(Line::from(vec![
1475            Span::styled(
1476                "  answers  ",
1477                Style::new().fg(ACCENT).add_modifier(Modifier::BOLD),
1478            ),
1479            dim(format!(
1480                "{} · {:.0} ms · {} attempt(s){}",
1481                res.model,
1482                elapsed.as_secs_f64() * 1000.0,
1483                res.meta.attempts,
1484                match (res.usage.input_tokens, res.usage.output_tokens) {
1485                    (Some(i), Some(o)) => format!(" · {i} in / {o} out tokens{money}"),
1486                    _ => String::new(),
1487                }
1488            )),
1489        ]));
1490        let answers: Vec<(String, Answer)> = res
1491            .answers
1492            .iter()
1493            .map(|(k, v)| (k.clone(), v.clone()))
1494            .collect();
1495        for (name, answer) in &answers {
1496            let threshold = self.session.threshold_of(name, self.threshold);
1497            self.extend(answer_lines(name, answer, threshold));
1498        }
1499        if let Some(id) = res.request_id() {
1500            self.note(format!("request_id {id}"));
1501        }
1502        self.last_raw = serde_json::to_string_pretty(&res.raw).ok();
1503    }
1504}
1505
1506fn masked(key: &str) -> String {
1507    let tail: String = key
1508        .chars()
1509        .rev()
1510        .take(4)
1511        .collect::<Vec<_>>()
1512        .into_iter()
1513        .rev()
1514        .collect();
1515    format!("…{tail}")
1516}