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