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 (
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 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 pub rates: Option<cost::Rates>,
109 pub last_raw: Option<String>,
110 pub builder: Option<Builder>,
112 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 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 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 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 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 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 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 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 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 self.builder = Some(Builder::new(state, ""));
854 }
855 }
856 }
857
858 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 fn apply_sketch(&mut self, parsed: &sketch::Parsed, text: &str) {
907 let before = self.session.request_json(&self.model_name());
908 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 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 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}