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