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