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