1use ratatui::Frame;
4use ratatui::layout::{Constraint, Layout, Rect};
5use ratatui::style::{Modifier, Style};
6use ratatui::text::{Line, Span, Text};
7use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph};
8
9use crate::app::{App, COMMANDS};
10use crate::builder::{Builder, Field};
11use crate::editor::Preview;
12use crate::format::*;
13use crate::sketch::Tag;
14use crate::{codegen, cost, highlight, lessons, mock, wrap};
15
16const LABEL: usize = 14;
18
19const SPINNER: [&str; 4] = ["⠋", "⠙", "⠹", "⠸"];
20
21pub fn render(frame: &mut Frame, app: &mut App) {
22 if app.sketch.is_some() {
23 sketch(frame, frame.area(), app);
24 return;
25 }
26 let [top, body, input] = Layout::vertical([
27 Constraint::Length(1),
28 Constraint::Min(3),
29 Constraint::Length(3),
30 ])
31 .areas(frame.area());
32
33 let [left, right] =
34 Layout::horizontal([Constraint::Min(40), Constraint::Length(36)]).areas(body);
35
36 status(frame, top, app);
37 transcript(frame, left, app);
38 panel(frame, right, app);
39 prompt(frame, input, app);
40 if app.builder.is_some() {
41 builder(frame, frame.area(), app);
42 }
43}
44
45fn status(frame: &mut Frame, area: Rect, app: &App) {
46 let mut spans = vec![
47 Span::styled(
48 " jev ",
49 Style::new().fg(ACCENT).add_modifier(Modifier::BOLD),
50 ),
51 dim("│ "),
52 Span::raw(app.model_name()),
53 dim(" │ "),
54 ];
55 spans.push(if app.mock {
56 Span::styled("MOCK", Style::new().fg(WARN).add_modifier(Modifier::BOLD))
57 } else {
58 Span::styled("LIVE", Style::new().fg(SCORE).add_modifier(Modifier::BOLD))
59 });
60 spans.push(dim(format!(
61 " │ {} question(s) │ threshold {:.2} │ lesson {}/{}",
62 app.session.questions.len(),
63 app.threshold,
64 app.lesson + 1,
65 lessons::LESSONS.len()
66 )));
67 frame.render_widget(Paragraph::new(Line::from(spans)), area);
68}
69
70fn transcript(frame: &mut Frame, area: Rect, app: &mut App) {
71 let block = Block::bordered()
72 .border_type(BorderType::Rounded)
73 .border_style(Style::new().fg(DIM))
74 .title(Line::from(dim(" transcript ")));
75 let inner = block.inner(area);
76 frame.render_widget(block, area);
77
78 let width = inner.width as usize;
79 let height = inner.height as usize;
80 let lines = wrap::wrap_all(&app.transcript, width);
81
82 let max_scroll = lines.len().saturating_sub(height);
84 app.scroll = app.scroll.min(max_scroll);
85 let end = lines.len() - app.scroll;
86 let start = end.saturating_sub(height);
87 frame.render_widget(
88 Paragraph::new(Text::from(lines[start..end].to_vec())),
89 inner,
90 );
91
92 if app.scroll > 0 {
93 let hint = format!(" {} line(s) below · Esc to follow ", app.scroll);
94 let w = (hint.len() as u16).min(area.width.saturating_sub(2));
95 let rect = Rect::new(
96 area.x + area.width.saturating_sub(w + 1),
97 area.y + area.height.saturating_sub(1),
98 w,
99 1,
100 );
101 frame.render_widget(Paragraph::new(Line::from(dim(hint))), rect);
102 }
103}
104
105fn panel(frame: &mut Frame, area: Rect, app: &App) {
106 let block = Block::bordered()
107 .border_type(BorderType::Rounded)
108 .border_style(Style::new().fg(DIM))
109 .title(Line::from(dim(" session ")));
110 let inner = block.inner(area);
111 frame.render_widget(block, area);
112
113 let mut lines: Vec<Line<'static>> = Vec::new();
114 lines.push(Line::from(Span::styled(
115 "state",
116 Style::new().fg(ACCENT).add_modifier(Modifier::BOLD),
117 )));
118 if app.session.state_is_empty() {
119 lines.push(Line::from(dim("(empty — type some text)")));
120 } else {
121 let preview = app.session.state_preview();
122 for line in wrap::wrap(&Line::from(preview), inner.width as usize)
123 .into_iter()
124 .take(6)
125 {
126 lines.push(line);
127 }
128 }
129 lines.push(Line::default());
130 lines.push(Line::from(Span::styled(
131 "questions",
132 Style::new().fg(ACCENT).add_modifier(Modifier::BOLD),
133 )));
134 if app.session.questions.is_empty() {
135 lines.push(Line::from(dim("(none — :noul :choice :score)")));
136 }
137 for (name, question) in &app.session.questions {
138 let kind = serde_json::to_value(question)
139 .ok()
140 .and_then(|v| v.get("type").and_then(|t| t.as_str()).map(str::to_owned))
141 .unwrap_or_else(|| "raw".into());
142 lines.push(Line::from(vec![
143 Span::styled("• ", Style::new().fg(color_for(&kind))),
144 Span::raw(name.clone()),
145 dim(format!(" {kind}")),
146 ]));
147 }
148
149 if !app.session.questions.is_empty() {
150 let estimate = cost::estimate(&app.session, &app.model_name());
151 lines.push(Line::default());
152 lines.push(Line::from(vec![
153 Span::styled("cost", Style::new().fg(ACCENT).add_modifier(Modifier::BOLD)),
154 dim(" estimated"),
155 ]));
156 lines.push(Line::from(dim(format!(
157 "≈ {} in / {} out tok",
158 estimate.input_tokens, estimate.output_tokens
159 ))));
160 lines.push(Line::from(match app.rates {
161 Some(rates) => Span::styled(
162 format!(
163 "{} per call",
164 cost::usd(cost::price_estimate(&estimate, rates).total)
165 ),
166 Style::new().fg(SCORE),
167 ),
168 None => dim(":cost 0.20/1.00 to price it"),
169 }));
170 }
171
172 lines.push(Line::default());
173 lines.push(Line::from(Span::styled(
174 format!("lesson {}", app.lesson + 1),
175 Style::new().fg(ACCENT).add_modifier(Modifier::BOLD),
176 )));
177 lines.push(Line::from(dim(lessons::LESSONS[app.lesson].title)));
178 if let Some(suggested) = &app.suggested {
179 for line in wrap::wrap(
180 &Line::from(Span::styled(
181 format!("^T {suggested}"),
182 Style::new().fg(SCORE),
183 )),
184 inner.width as usize,
185 )
186 .into_iter()
187 .take(4)
188 {
189 lines.push(line);
190 }
191 }
192
193 lines.push(Line::default());
194 for hint in [
195 "Enter send the session",
196 "^T/^N try / next lesson",
197 ":help every command",
198 ":json the request body",
199 ":rust this session as code",
200 ] {
201 lines.push(Line::from(dim(hint)));
202 }
203
204 frame.render_widget(Paragraph::new(Text::from(lines)), inner);
205}
206
207fn prompt(frame: &mut Frame, area: Rect, app: &App) {
208 let title = if app.pending {
209 Line::from(vec![
210 Span::styled(
211 format!(" {} ", SPINNER[app.spinner % SPINNER.len()]),
212 Style::new().fg(ACCENT),
213 ),
214 dim("waiting for the API "),
215 ])
216 } else {
217 Line::from(dim(" ask "))
218 };
219 let block = Block::bordered()
220 .border_type(BorderType::Rounded)
221 .border_style(Style::new().fg(if app.pending { ACCENT } else { DIM }))
222 .title(title);
223 let inner = block.inner(area);
224 frame.render_widget(block, area);
225
226 let width = inner.width.saturating_sub(2) as usize;
227 let chars: Vec<char> = app.input.chars().collect();
228 let offset = app.cursor.saturating_sub(width);
229 let visible: String = chars[offset.min(chars.len())..].iter().collect();
230
231 let line = if app.input.is_empty() {
232 Line::from(vec![
233 Span::styled("› ", Style::new().fg(ACCENT)),
234 dim("type text to set the state, :help for commands, Enter to send"),
235 ])
236 } else {
237 let mut spans = vec![Span::styled("› ", Style::new().fg(ACCENT))];
238 spans.extend(highlight::command(&visible, |cmd| {
239 COMMANDS.iter().any(|(c, _)| *c == cmd)
240 }));
241 Line::from(spans)
242 };
243 frame.render_widget(Paragraph::new(line), inner);
244 frame.set_cursor_position((inner.x + 2 + (app.cursor - offset) as u16, inner.y));
245}
246
247fn builder(frame: &mut Frame, area: Rect, app: &App) {
249 let Some(b) = app.builder.as_ref() else {
250 return;
251 };
252 let popup = centered(area, 92, 86);
253 frame.render_widget(Clear, popup);
254 let block = Block::bordered()
255 .border_type(BorderType::Rounded)
256 .border_style(Style::new().fg(ACCENT))
257 .title(Line::from(Span::styled(
258 " build a question ",
259 Style::new().fg(ACCENT).add_modifier(Modifier::BOLD),
260 )))
261 .title_bottom(Line::from(dim(
262 " Tab move · ^O add row · ^X drop row · ^S add question · Esc close ",
263 )));
264 let inner = block.inner(popup);
265 frame.render_widget(block, popup);
266
267 let [form_area, gutter, preview_area] = Layout::horizontal([
268 Constraint::Percentage(55),
269 Constraint::Length(2),
270 Constraint::Min(20),
271 ])
272 .areas(inner);
273 frame.render_widget(
274 Block::new()
275 .borders(Borders::LEFT)
276 .border_style(Style::new().fg(DIM)),
277 Rect {
278 x: gutter.x + 1,
279 ..gutter
280 },
281 );
282
283 let (lines, cursor) = form(b, form_area.width as usize);
284 frame.render_widget(Paragraph::new(Text::from(lines)), form_area);
285 if let Some((col, row)) = cursor
286 && row < form_area.height as usize
287 {
288 frame.set_cursor_position((
289 form_area.x + (col as u16).min(form_area.width.saturating_sub(1)),
290 form_area.y + row as u16,
291 ));
292 }
293
294 let [json_area, command_area] =
295 Layout::vertical([Constraint::Min(3), Constraint::Length(6)]).areas(preview_area);
296
297 let pretty = serde_json::to_string_pretty(&b.preview()).unwrap_or_default();
298 let mut json_lines = vec![Line::from(dim("questions"))];
299 json_lines.extend(highlight::json(&pretty));
300 frame.render_widget(Paragraph::new(Text::from(json_lines)), json_area);
301
302 let mut tail = vec![Line::from(dim("same thing, one line"))];
303 let command = b.as_command();
304 tail.extend(wrap::wrap(
305 &Line::from(highlight::command(&command, |c| {
306 COMMANDS.iter().any(|(k, _)| *k == c)
307 })),
308 command_area.width as usize,
309 ));
310 if let Some(message) = &b.message {
311 tail.push(Line::default());
312 tail.push(Line::from(Span::styled(
313 message.clone(),
314 Style::new().fg(BAD),
315 )));
316 }
317 frame.render_widget(Paragraph::new(Text::from(tail)), command_area);
318}
319
320fn form(b: &Builder, width: usize) -> (Vec<Line<'static>>, Option<(usize, usize)>) {
322 let mut lines: Vec<Line<'static>> = Vec::new();
323 let mut cursor = None;
324 let focused = b.focused();
325 let field_width = width.saturating_sub(LABEL + 1).max(8);
326
327 let row = |lines: &mut Vec<Line<'static>>,
328 cursor: &mut Option<(usize, usize)>,
329 label: &str,
330 field: Field,
331 hint: &str| {
332 let is_focused = field == focused;
333 let text = b.text(field);
334 let (shown, offset) = view(text, b.cursor, field_width, is_focused);
335 let mut spans = vec![Span::styled(
336 format!("{label:LABEL$}"),
337 Style::new().fg(if is_focused { ACCENT } else { DIM }),
338 )];
339 if shown.is_empty() && !hint.is_empty() {
340 spans.push(dim(hint.to_owned()));
341 } else {
342 spans.push(Span::styled(
343 shown,
344 if is_focused {
345 Style::new().add_modifier(Modifier::BOLD)
346 } else {
347 Style::new()
348 },
349 ));
350 }
351 if is_focused {
352 *cursor = Some((LABEL + b.cursor.saturating_sub(offset), lines.len()));
353 }
354 lines.push(Line::from(spans));
355 };
356
357 row(
358 &mut lines,
359 &mut cursor,
360 "state",
361 Field::State,
362 "the text being judged",
363 );
364 lines.push(Line::default());
365 row(
366 &mut lines,
367 &mut cursor,
368 "name",
369 Field::Name,
370 "answers come back under this",
371 );
372
373 let kind_focused = focused == Field::Kind;
375 lines.push(Line::from(vec![
376 Span::styled(
377 format!("{:LABEL$}", "type"),
378 Style::new().fg(if kind_focused { ACCENT } else { DIM }),
379 ),
380 Span::styled(
381 format!("‹ {} ›", b.kind.label()),
382 Style::new()
383 .fg(color_for(b.kind.label()))
384 .add_modifier(Modifier::BOLD),
385 ),
386 dim(format!(" {}", b.kind.about())),
387 ]));
388 if kind_focused {
389 lines.push(Line::from(vec![
390 Span::raw(" ".repeat(LABEL)),
391 dim("← → or n/c/s to switch"),
392 ]));
393 }
394 row(
395 &mut lines,
396 &mut cursor,
397 "instructions",
398 Field::Instructions,
399 "what the model should decide",
400 );
401 lines.push(Line::default());
402
403 match b.kind {
404 crate::builder::Kind::Noul => {
405 row(&mut lines, &mut cursor, "yes means", Field::Yes, "optional");
406 row(&mut lines, &mut cursor, "no means", Field::No, "optional");
407 }
408 crate::builder::Kind::Choice => {
409 for i in 0..b.options.len() {
410 row(
411 &mut lines,
412 &mut cursor,
413 &format!("option {}", i + 1),
414 Field::OptionLabel(i),
415 "label",
416 );
417 row(
418 &mut lines,
419 &mut cursor,
420 " describe",
421 Field::OptionDesc(i),
422 "optional, but this is what sharpens it",
423 );
424 }
425 }
426 crate::builder::Kind::Score => {
427 for i in 0..b.levels.len() {
428 row(
429 &mut lines,
430 &mut cursor,
431 &format!("level {i}"),
432 Field::Level(i),
433 if i == 0 { "lowest" } else { "" },
434 );
435 }
436 }
437 }
438 (lines, cursor)
439}
440
441fn view(text: &str, cursor: usize, width: usize, focused: bool) -> (String, usize) {
443 let chars: Vec<char> = text.chars().collect();
444 if chars.len() < width {
445 return (text.to_owned(), 0);
446 }
447 if !focused {
448 let cut: String = chars[..width.saturating_sub(1)].iter().collect();
449 return (format!("{cut}…"), 0);
450 }
451 let offset = cursor.saturating_sub(width.saturating_sub(1));
452 (chars[offset.min(chars.len())..].iter().collect(), offset)
453}
454
455const GUTTER: usize = 8;
457
458fn sketch(frame: &mut Frame, area: Rect, app: &mut App) {
461 let threshold = app.threshold;
462 let default_model = app.model_name();
463 let rates = app.rates;
464 let Some(ed) = app.sketch.as_mut() else {
465 return;
466 };
467 let parsed = ed.parsed();
468
469 frame.render_widget(Clear, area);
470 let block = Block::bordered()
471 .border_type(BorderType::Rounded)
472 .border_style(Style::new().fg(ACCENT))
473 .title(Line::from(Span::styled(
474 " sketch · the request as one page ",
475 Style::new().fg(ACCENT).add_modifier(Modifier::BOLD),
476 )))
477 .title_bottom(Line::from(dim(
478 " ^S apply · ^G apply & send · ^P preview · ^X/^U cut/paste line · Alt-↑↓ move line · Esc close ",
479 )));
480 let inner = block.inner(area);
481 frame.render_widget(block, area);
482
483 let [page_area, status_area] =
484 Layout::vertical([Constraint::Min(3), Constraint::Length(1)]).areas(inner);
485 let [edit_area, gutter, preview_area] = Layout::horizontal([
486 Constraint::Percentage(56),
487 Constraint::Length(2),
488 Constraint::Min(24),
489 ])
490 .areas(page_area);
491 frame.render_widget(
492 Block::new()
493 .borders(Borders::LEFT)
494 .border_style(Style::new().fg(DIM)),
495 Rect {
496 x: gutter.x + 1,
497 ..gutter
498 },
499 );
500
501 let height = edit_area.height as usize;
503 if height > 0 {
504 if ed.row < ed.top {
505 ed.top = ed.row;
506 } else if ed.row >= ed.top + height {
507 ed.top = ed.row + 1 - height;
508 }
509 }
510 let text_width = (edit_area.width as usize).saturating_sub(GUTTER).max(8);
511 let state_empty = parsed.tags.iter().all(|t| !matches!(t, Tag::State));
512 let mut lines: Vec<Line<'static>> = Vec::new();
513 let mut cursor = None;
514 let mut prev = None;
515 for (i, line) in ed.lines.iter().enumerate().skip(ed.top).take(height) {
516 let tag = parsed.tags.get(i).copied().unwrap_or(Tag::Blank);
517 let is_cur = i == ed.row;
518 let problem = parsed.problem_at(i).is_some();
519 let label = if tag == Tag::State && prev == Some(Tag::State) {
521 ""
522 } else {
523 tag.label()
524 };
525 prev = Some(tag);
526
527 let tag_style = Style::new().fg(tag.color());
528 let mut spans = vec![
529 Span::styled(
530 format!("{label:<6}"),
531 if tag.is_head() {
532 tag_style.add_modifier(Modifier::BOLD)
533 } else {
534 tag_style
535 },
536 ),
537 Span::styled(
538 if problem { "!" } else { " " },
539 Style::new().fg(BAD).add_modifier(Modifier::BOLD),
540 ),
541 Span::styled("│", Style::new().fg(if is_cur { ACCENT } else { DIM })),
542 ];
543 let (shown, offset) = view(line, ed.col, text_width, is_cur);
544 if shown.is_empty() && i == 0 && state_empty {
545 spans.push(dim("the state — the text or JSON the questions are about"));
546 } else {
547 let style = match tag {
548 t if t.is_head() => Style::new().fg(t.color()).add_modifier(Modifier::BOLD),
549 Tag::Rule | Tag::Comment => Style::new().fg(DIM),
550 Tag::State | Tag::Blank => Style::new(),
551 t => Style::new().fg(t.color()),
552 };
553 spans.push(Span::styled(shown, style));
554 }
555 if is_cur {
556 cursor = Some((GUTTER + ed.col.saturating_sub(offset), lines.len()));
557 }
558 lines.push(Line::from(spans));
559 }
560 frame.render_widget(Paragraph::new(Text::from(lines)), edit_area);
561 if let Some((col, row)) = cursor {
562 frame.set_cursor_position((
563 edit_area.x + (col as u16).min(edit_area.width.saturating_sub(1)),
564 edit_area.y + row as u16,
565 ));
566 }
567
568 let mut tabs: Vec<Span<'static>> = Vec::new();
570 for (i, p) in Preview::ALL.iter().enumerate() {
571 if i > 0 {
572 tabs.push(dim(" · "));
573 }
574 tabs.push(if *p == ed.preview {
575 Span::styled(
576 p.label(),
577 Style::new().fg(ACCENT).add_modifier(Modifier::BOLD),
578 )
579 } else {
580 dim(p.label())
581 });
582 }
583 tabs.push(dim(" ^P"));
584 if !parsed.problems.is_empty() {
585 tabs.push(Span::styled(
586 format!(" {} problem(s)", parsed.problems.len()),
587 Style::new().fg(BAD),
588 ));
589 }
590 let mut preview: Vec<Line<'static>> = vec![Line::from(tabs), Line::default()];
591 if !parsed.problems.is_empty() {
593 preview.push(Line::from(Span::styled(
594 "problems",
595 Style::new().fg(BAD).add_modifier(Modifier::BOLD),
596 )));
597 for p in parsed.problems.iter().take(6) {
598 preview.push(Line::from(vec![
599 Span::styled(format!(" {:>3} ", p.line + 1), Style::new().fg(BAD)),
600 Span::raw(p.message.clone()),
601 ]));
602 }
603 preview.push(Line::default());
604 }
605
606 let session = parsed.to_session();
607 let model = session.model.clone().unwrap_or(default_model);
608 match ed.preview {
609 Preview::Json => preview.extend(highlight::json(&session.request_json(&model))),
610 Preview::Answers => {
611 if session.questions.is_empty() {
612 preview.push(Line::from(dim(
613 " add a question below the --- line to see the shape of its answer",
614 )));
615 } else {
616 preview.push(Line::from(dim(
617 " simulated answers — the shape is real, the numbers are not",
618 )));
619 for (name, q) in &session.questions {
620 let json = serde_json::to_value(q).unwrap_or_default();
621 match mock::answer(&session.state, name, &json) {
622 Some(a) => preview.extend(answer_lines(name, &a, threshold)),
623 None => preview.push(Line::from(dim(format!(
624 " {name}: no simulation for this question shape"
625 )))),
626 }
627 }
628 }
629 }
630 Preview::Rust => {
631 preview.extend(highlight::rust(&codegen::rust(&session, &model, threshold)))
632 }
633 Preview::Cost => {
634 if session.questions.is_empty() {
635 preview.push(Line::from(dim(
636 " add a question below the --- line to see what a call would cost",
637 )));
638 } else {
639 preview.extend(cost_lines(
640 &cost::estimate(&session, &model),
641 rates,
642 ":cost 0.20/1.00 prices it, dollars per million tokens",
643 ));
644 }
645 }
646 }
647 let wrapped = wrap::wrap_all(&preview, preview_area.width as usize);
648 frame.render_widget(Paragraph::new(Text::from(wrapped)), preview_area);
649
650 let tag = parsed.tags.get(ed.row).copied().unwrap_or(Tag::Blank);
652 let below_rule = parsed
653 .tags
654 .iter()
655 .position(|t| *t == Tag::Rule)
656 .is_some_and(|r| ed.row > r);
657 let status = if let Some(m) = &ed.message {
658 Span::styled(m.clone(), Style::new().fg(WARN))
659 } else if let Some(p) = parsed.problem_at(ed.row) {
660 Span::styled(p.message.clone(), Style::new().fg(BAD))
661 } else {
662 dim(hint_for(tag, below_rule))
663 };
664 frame.render_widget(
665 Paragraph::new(Line::from(vec![Span::raw(" "), status])),
666 status_area,
667 );
668}
669
670fn hint_for(tag: Tag, below_rule: bool) -> &'static str {
672 match tag {
673 Tag::State => "state — the text or JSON the questions are about; a --- line ends it",
674 Tag::Rule => "--- separates the state above from the questions below",
675 Tag::Blank if !below_rule => {
676 "state — the text or JSON the questions are about; a --- line ends it"
677 }
678 Tag::Blank => {
679 "name? asks yes/no · name: then `label = why` lines (choice) or `low < high` (score) · name! {json} sends it raw"
680 }
681 Tag::Comment => "a comment — ignored",
682 Tag::Model => "@model pins the model this session sends to",
683 Tag::Noul => {
684 "noul — the answer is the probability of yes; `yes:` and `no:` lines say what each means"
685 }
686 Tag::Yes | Tag::No => "what a yes or a no means — sharper criteria, higher confidence",
687 Tag::Choice => "choice — one label out of these options; `label = why` describes each",
688 Tag::Option => "an option — `label = when it applies`; a bare label works but is vaguer",
689 Tag::Score => {
690 "score — ordered levels, lowest first; the answer is a weighted position along them"
691 }
692 Tag::Level => "a level — write them lowest to highest, joined with <",
693 Tag::Raw => "raw — a JSON object sent as it is; it needs a `type`",
694 Tag::Json => "continues the raw JSON above",
695 Tag::Stray => "this line could not be placed",
696 }
697}
698
699fn centered(area: Rect, percent_x: u16, percent_y: u16) -> Rect {
700 let [_, middle, _] = Layout::vertical([
701 Constraint::Percentage((100 - percent_y) / 2),
702 Constraint::Percentage(percent_y),
703 Constraint::Percentage((100 - percent_y) / 2),
704 ])
705 .areas(area);
706 let [_, center, _] = Layout::horizontal([
707 Constraint::Percentage((100 - percent_x) / 2),
708 Constraint::Percentage(percent_x),
709 Constraint::Percentage((100 - percent_x) / 2),
710 ])
711 .areas(middle);
712 center
713}