1use 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
18pub 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 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 pub rates: Option<cost::Rates>,
105 pub last_raw: Option<String>,
106 pub builder: Option<Builder>,
108 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 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 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 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 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 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 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 self.builder = Some(Builder::new(state, ""));
764 }
765 }
766 }
767
768 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 fn apply_sketch(&mut self, parsed: &sketch::Parsed, text: &str) {
817 let before = self.session.request_json(&self.model_name());
818 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 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 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}