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