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, 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 lines.push(Line::default());
150 lines.push(Line::from(Span::styled(
151 format!("lesson {}", app.lesson + 1),
152 Style::new().fg(ACCENT).add_modifier(Modifier::BOLD),
153 )));
154 lines.push(Line::from(dim(lessons::LESSONS[app.lesson].title)));
155 if let Some(suggested) = &app.suggested {
156 for line in wrap::wrap(
157 &Line::from(Span::styled(
158 format!("^T {suggested}"),
159 Style::new().fg(SCORE),
160 )),
161 inner.width as usize,
162 )
163 .into_iter()
164 .take(4)
165 {
166 lines.push(line);
167 }
168 }
169
170 lines.push(Line::default());
171 for hint in [
172 "Enter send the session",
173 "^T/^N try / next lesson",
174 ":help every command",
175 ":json the request body",
176 ":rust this session as code",
177 ] {
178 lines.push(Line::from(dim(hint)));
179 }
180
181 frame.render_widget(Paragraph::new(Text::from(lines)), inner);
182}
183
184fn prompt(frame: &mut Frame, area: Rect, app: &App) {
185 let title = if app.pending {
186 Line::from(vec![
187 Span::styled(
188 format!(" {} ", SPINNER[app.spinner % SPINNER.len()]),
189 Style::new().fg(ACCENT),
190 ),
191 dim("waiting for the API "),
192 ])
193 } else {
194 Line::from(dim(" ask "))
195 };
196 let block = Block::bordered()
197 .border_type(BorderType::Rounded)
198 .border_style(Style::new().fg(if app.pending { ACCENT } else { DIM }))
199 .title(title);
200 let inner = block.inner(area);
201 frame.render_widget(block, area);
202
203 let width = inner.width.saturating_sub(2) as usize;
204 let chars: Vec<char> = app.input.chars().collect();
205 let offset = app.cursor.saturating_sub(width);
206 let visible: String = chars[offset.min(chars.len())..].iter().collect();
207
208 let line = if app.input.is_empty() {
209 Line::from(vec![
210 Span::styled("› ", Style::new().fg(ACCENT)),
211 dim("type text to set the state, :help for commands, Enter to send"),
212 ])
213 } else {
214 let mut spans = vec![Span::styled("› ", Style::new().fg(ACCENT))];
215 spans.extend(highlight::command(&visible, |cmd| {
216 COMMANDS.iter().any(|(c, _)| *c == cmd)
217 }));
218 Line::from(spans)
219 };
220 frame.render_widget(Paragraph::new(line), inner);
221 frame.set_cursor_position((inner.x + 2 + (app.cursor - offset) as u16, inner.y));
222}
223
224fn builder(frame: &mut Frame, area: Rect, app: &App) {
226 let Some(b) = app.builder.as_ref() else {
227 return;
228 };
229 let popup = centered(area, 92, 86);
230 frame.render_widget(Clear, popup);
231 let block = Block::bordered()
232 .border_type(BorderType::Rounded)
233 .border_style(Style::new().fg(ACCENT))
234 .title(Line::from(Span::styled(
235 " build a question ",
236 Style::new().fg(ACCENT).add_modifier(Modifier::BOLD),
237 )))
238 .title_bottom(Line::from(dim(
239 " Tab move · ^O add row · ^X drop row · ^S add question · Esc close ",
240 )));
241 let inner = block.inner(popup);
242 frame.render_widget(block, popup);
243
244 let [form_area, gutter, preview_area] = Layout::horizontal([
245 Constraint::Percentage(55),
246 Constraint::Length(2),
247 Constraint::Min(20),
248 ])
249 .areas(inner);
250 frame.render_widget(
251 Block::new()
252 .borders(Borders::LEFT)
253 .border_style(Style::new().fg(DIM)),
254 Rect {
255 x: gutter.x + 1,
256 ..gutter
257 },
258 );
259
260 let (lines, cursor) = form(b, form_area.width as usize);
261 frame.render_widget(Paragraph::new(Text::from(lines)), form_area);
262 if let Some((col, row)) = cursor
263 && row < form_area.height as usize
264 {
265 frame.set_cursor_position((
266 form_area.x + (col as u16).min(form_area.width.saturating_sub(1)),
267 form_area.y + row as u16,
268 ));
269 }
270
271 let [json_area, command_area] =
272 Layout::vertical([Constraint::Min(3), Constraint::Length(6)]).areas(preview_area);
273
274 let pretty = serde_json::to_string_pretty(&b.preview()).unwrap_or_default();
275 let mut json_lines = vec![Line::from(dim("questions"))];
276 json_lines.extend(highlight::json(&pretty));
277 frame.render_widget(Paragraph::new(Text::from(json_lines)), json_area);
278
279 let mut tail = vec![Line::from(dim("same thing, one line"))];
280 let command = b.as_command();
281 tail.extend(wrap::wrap(
282 &Line::from(highlight::command(&command, |c| {
283 COMMANDS.iter().any(|(k, _)| *k == c)
284 })),
285 command_area.width as usize,
286 ));
287 if let Some(message) = &b.message {
288 tail.push(Line::default());
289 tail.push(Line::from(Span::styled(
290 message.clone(),
291 Style::new().fg(BAD),
292 )));
293 }
294 frame.render_widget(Paragraph::new(Text::from(tail)), command_area);
295}
296
297fn form(b: &Builder, width: usize) -> (Vec<Line<'static>>, Option<(usize, usize)>) {
299 let mut lines: Vec<Line<'static>> = Vec::new();
300 let mut cursor = None;
301 let focused = b.focused();
302 let field_width = width.saturating_sub(LABEL + 1).max(8);
303
304 let row = |lines: &mut Vec<Line<'static>>,
305 cursor: &mut Option<(usize, usize)>,
306 label: &str,
307 field: Field,
308 hint: &str| {
309 let is_focused = field == focused;
310 let text = b.text(field);
311 let (shown, offset) = view(text, b.cursor, field_width, is_focused);
312 let mut spans = vec![Span::styled(
313 format!("{label:LABEL$}"),
314 Style::new().fg(if is_focused { ACCENT } else { DIM }),
315 )];
316 if shown.is_empty() && !hint.is_empty() {
317 spans.push(dim(hint.to_owned()));
318 } else {
319 spans.push(Span::styled(
320 shown,
321 if is_focused {
322 Style::new().add_modifier(Modifier::BOLD)
323 } else {
324 Style::new()
325 },
326 ));
327 }
328 if is_focused {
329 *cursor = Some((LABEL + b.cursor.saturating_sub(offset), lines.len()));
330 }
331 lines.push(Line::from(spans));
332 };
333
334 row(
335 &mut lines,
336 &mut cursor,
337 "state",
338 Field::State,
339 "the text being judged",
340 );
341 lines.push(Line::default());
342 row(
343 &mut lines,
344 &mut cursor,
345 "name",
346 Field::Name,
347 "answers come back under this",
348 );
349
350 let kind_focused = focused == Field::Kind;
352 lines.push(Line::from(vec![
353 Span::styled(
354 format!("{:LABEL$}", "type"),
355 Style::new().fg(if kind_focused { ACCENT } else { DIM }),
356 ),
357 Span::styled(
358 format!("‹ {} ›", b.kind.label()),
359 Style::new()
360 .fg(color_for(b.kind.label()))
361 .add_modifier(Modifier::BOLD),
362 ),
363 dim(format!(" {}", b.kind.about())),
364 ]));
365 if kind_focused {
366 lines.push(Line::from(vec![
367 Span::raw(" ".repeat(LABEL)),
368 dim("← → or n/c/s to switch"),
369 ]));
370 }
371 row(
372 &mut lines,
373 &mut cursor,
374 "instructions",
375 Field::Instructions,
376 "what the model should decide",
377 );
378 lines.push(Line::default());
379
380 match b.kind {
381 crate::builder::Kind::Noul => {
382 row(&mut lines, &mut cursor, "yes means", Field::Yes, "optional");
383 row(&mut lines, &mut cursor, "no means", Field::No, "optional");
384 }
385 crate::builder::Kind::Choice => {
386 for i in 0..b.options.len() {
387 row(
388 &mut lines,
389 &mut cursor,
390 &format!("option {}", i + 1),
391 Field::OptionLabel(i),
392 "label",
393 );
394 row(
395 &mut lines,
396 &mut cursor,
397 " describe",
398 Field::OptionDesc(i),
399 "optional, but this is what sharpens it",
400 );
401 }
402 }
403 crate::builder::Kind::Score => {
404 for i in 0..b.levels.len() {
405 row(
406 &mut lines,
407 &mut cursor,
408 &format!("level {i}"),
409 Field::Level(i),
410 if i == 0 { "lowest" } else { "" },
411 );
412 }
413 }
414 }
415 (lines, cursor)
416}
417
418fn view(text: &str, cursor: usize, width: usize, focused: bool) -> (String, usize) {
420 let chars: Vec<char> = text.chars().collect();
421 if chars.len() < width {
422 return (text.to_owned(), 0);
423 }
424 if !focused {
425 let cut: String = chars[..width.saturating_sub(1)].iter().collect();
426 return (format!("{cut}…"), 0);
427 }
428 let offset = cursor.saturating_sub(width.saturating_sub(1));
429 (chars[offset.min(chars.len())..].iter().collect(), offset)
430}
431
432const GUTTER: usize = 8;
434
435fn sketch(frame: &mut Frame, area: Rect, app: &mut App) {
438 let threshold = app.threshold;
439 let default_model = app.model_name();
440 let Some(ed) = app.sketch.as_mut() else {
441 return;
442 };
443 let parsed = ed.parsed();
444
445 frame.render_widget(Clear, area);
446 let block = Block::bordered()
447 .border_type(BorderType::Rounded)
448 .border_style(Style::new().fg(ACCENT))
449 .title(Line::from(Span::styled(
450 " sketch · the request as one page ",
451 Style::new().fg(ACCENT).add_modifier(Modifier::BOLD),
452 )))
453 .title_bottom(Line::from(dim(
454 " ^S apply · ^G apply & send · ^P preview · ^X/^U cut/paste line · Alt-↑↓ move line · Esc close ",
455 )));
456 let inner = block.inner(area);
457 frame.render_widget(block, area);
458
459 let [page_area, status_area] =
460 Layout::vertical([Constraint::Min(3), Constraint::Length(1)]).areas(inner);
461 let [edit_area, gutter, preview_area] = Layout::horizontal([
462 Constraint::Percentage(56),
463 Constraint::Length(2),
464 Constraint::Min(24),
465 ])
466 .areas(page_area);
467 frame.render_widget(
468 Block::new()
469 .borders(Borders::LEFT)
470 .border_style(Style::new().fg(DIM)),
471 Rect {
472 x: gutter.x + 1,
473 ..gutter
474 },
475 );
476
477 let height = edit_area.height as usize;
479 if height > 0 {
480 if ed.row < ed.top {
481 ed.top = ed.row;
482 } else if ed.row >= ed.top + height {
483 ed.top = ed.row + 1 - height;
484 }
485 }
486 let text_width = (edit_area.width as usize).saturating_sub(GUTTER).max(8);
487 let state_empty = parsed.tags.iter().all(|t| !matches!(t, Tag::State));
488 let mut lines: Vec<Line<'static>> = Vec::new();
489 let mut cursor = None;
490 let mut prev = None;
491 for (i, line) in ed.lines.iter().enumerate().skip(ed.top).take(height) {
492 let tag = parsed.tags.get(i).copied().unwrap_or(Tag::Blank);
493 let is_cur = i == ed.row;
494 let problem = parsed.problem_at(i).is_some();
495 let label = if tag == Tag::State && prev == Some(Tag::State) {
497 ""
498 } else {
499 tag.label()
500 };
501 prev = Some(tag);
502
503 let tag_style = Style::new().fg(tag.color());
504 let mut spans = vec![
505 Span::styled(
506 format!("{label:<6}"),
507 if tag.is_head() {
508 tag_style.add_modifier(Modifier::BOLD)
509 } else {
510 tag_style
511 },
512 ),
513 Span::styled(
514 if problem { "!" } else { " " },
515 Style::new().fg(BAD).add_modifier(Modifier::BOLD),
516 ),
517 Span::styled("│", Style::new().fg(if is_cur { ACCENT } else { DIM })),
518 ];
519 let (shown, offset) = view(line, ed.col, text_width, is_cur);
520 if shown.is_empty() && i == 0 && state_empty {
521 spans.push(dim("the state — the text or JSON the questions are about"));
522 } else {
523 let style = match tag {
524 t if t.is_head() => Style::new().fg(t.color()).add_modifier(Modifier::BOLD),
525 Tag::Rule | Tag::Comment => Style::new().fg(DIM),
526 Tag::State | Tag::Blank => Style::new(),
527 t => Style::new().fg(t.color()),
528 };
529 spans.push(Span::styled(shown, style));
530 }
531 if is_cur {
532 cursor = Some((GUTTER + ed.col.saturating_sub(offset), lines.len()));
533 }
534 lines.push(Line::from(spans));
535 }
536 frame.render_widget(Paragraph::new(Text::from(lines)), edit_area);
537 if let Some((col, row)) = cursor {
538 frame.set_cursor_position((
539 edit_area.x + (col as u16).min(edit_area.width.saturating_sub(1)),
540 edit_area.y + row as u16,
541 ));
542 }
543
544 let mut tabs: Vec<Span<'static>> = Vec::new();
546 for (i, p) in Preview::ALL.iter().enumerate() {
547 if i > 0 {
548 tabs.push(dim(" · "));
549 }
550 tabs.push(if *p == ed.preview {
551 Span::styled(
552 p.label(),
553 Style::new().fg(ACCENT).add_modifier(Modifier::BOLD),
554 )
555 } else {
556 dim(p.label())
557 });
558 }
559 tabs.push(dim(" ^P"));
560 if !parsed.problems.is_empty() {
561 tabs.push(Span::styled(
562 format!(" {} problem(s)", parsed.problems.len()),
563 Style::new().fg(BAD),
564 ));
565 }
566 let mut preview: Vec<Line<'static>> = vec![Line::from(tabs), Line::default()];
567 if !parsed.problems.is_empty() {
569 preview.push(Line::from(Span::styled(
570 "problems",
571 Style::new().fg(BAD).add_modifier(Modifier::BOLD),
572 )));
573 for p in parsed.problems.iter().take(6) {
574 preview.push(Line::from(vec![
575 Span::styled(format!(" {:>3} ", p.line + 1), Style::new().fg(BAD)),
576 Span::raw(p.message.clone()),
577 ]));
578 }
579 preview.push(Line::default());
580 }
581
582 let session = parsed.to_session();
583 let model = session.model.clone().unwrap_or(default_model);
584 match ed.preview {
585 Preview::Json => preview.extend(highlight::json(&session.request_json(&model))),
586 Preview::Answers => {
587 if session.questions.is_empty() {
588 preview.push(Line::from(dim(
589 " add a question below the --- line to see the shape of its answer",
590 )));
591 } else {
592 preview.push(Line::from(dim(
593 " simulated answers — the shape is real, the numbers are not",
594 )));
595 for (name, q) in &session.questions {
596 let json = serde_json::to_value(q).unwrap_or_default();
597 match mock::answer(&session.state, name, &json) {
598 Some(a) => preview.extend(answer_lines(name, &a, threshold)),
599 None => preview.push(Line::from(dim(format!(
600 " {name}: no simulation for this question shape"
601 )))),
602 }
603 }
604 }
605 }
606 Preview::Rust => {
607 preview.extend(highlight::rust(&codegen::rust(&session, &model, threshold)))
608 }
609 }
610 let wrapped = wrap::wrap_all(&preview, preview_area.width as usize);
611 frame.render_widget(Paragraph::new(Text::from(wrapped)), preview_area);
612
613 let tag = parsed.tags.get(ed.row).copied().unwrap_or(Tag::Blank);
615 let below_rule = parsed
616 .tags
617 .iter()
618 .position(|t| *t == Tag::Rule)
619 .is_some_and(|r| ed.row > r);
620 let status = if let Some(m) = &ed.message {
621 Span::styled(m.clone(), Style::new().fg(WARN))
622 } else if let Some(p) = parsed.problem_at(ed.row) {
623 Span::styled(p.message.clone(), Style::new().fg(BAD))
624 } else {
625 dim(hint_for(tag, below_rule))
626 };
627 frame.render_widget(
628 Paragraph::new(Line::from(vec![Span::raw(" "), status])),
629 status_area,
630 );
631}
632
633fn hint_for(tag: Tag, below_rule: bool) -> &'static str {
635 match tag {
636 Tag::State => "state — the text or JSON the questions are about; a --- line ends it",
637 Tag::Rule => "--- separates the state above from the questions below",
638 Tag::Blank if !below_rule => {
639 "state — the text or JSON the questions are about; a --- line ends it"
640 }
641 Tag::Blank => {
642 "name? asks yes/no · name: then `label = why` lines (choice) or `low < high` (score) · name! {json} sends it raw"
643 }
644 Tag::Comment => "a comment — ignored",
645 Tag::Model => "@model pins the model this session sends to",
646 Tag::Noul => {
647 "noul — the answer is the probability of yes; `yes:` and `no:` lines say what each means"
648 }
649 Tag::Yes | Tag::No => "what a yes or a no means — sharper criteria, higher confidence",
650 Tag::Choice => "choice — one label out of these options; `label = why` describes each",
651 Tag::Option => "an option — `label = when it applies`; a bare label works but is vaguer",
652 Tag::Score => {
653 "score — ordered levels, lowest first; the answer is a weighted position along them"
654 }
655 Tag::Level => "a level — write them lowest to highest, joined with <",
656 Tag::Raw => "raw — a JSON object sent as it is; it needs a `type`",
657 Tag::Json => "continues the raw JSON above",
658 Tag::Stray => "this line could not be placed",
659 }
660}
661
662fn centered(area: Rect, percent_x: u16, percent_y: u16) -> Rect {
663 let [_, middle, _] = Layout::vertical([
664 Constraint::Percentage((100 - percent_y) / 2),
665 Constraint::Percentage(percent_y),
666 Constraint::Percentage((100 - percent_y) / 2),
667 ])
668 .areas(area);
669 let [_, center, _] = Layout::horizontal([
670 Constraint::Percentage((100 - percent_x) / 2),
671 Constraint::Percentage(percent_x),
672 Constraint::Percentage((100 - percent_x) / 2),
673 ])
674 .areas(middle);
675 center
676}