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