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