Skip to main content

jev_repl/
ui.rs

1//! Layout: a status strip, the transcript, a live view of the session, and the input line.
2
3use 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
16/// Width of the label column in builder mode.
17const 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    // `scroll` counts lines back from the tail, so new output stays in view at rest.
83    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
224/// The builder-mode popup: a form on the left, the JSON it produces on the right.
225fn 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
297/// The form rows, plus where the terminal cursor belongs.
298fn 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    // The type row is a cycler, not a text field.
351    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
418/// Slide a long value so the cursor stays visible; returns the text and the column it starts at.
419fn 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
432/// Width of the sketch gutter: a six-letter tag, a problem mark, and the rule.
433const GUTTER: usize = 8;
434
435/// Sketch mode: the page on the left with a gutter saying what each line became, a preview on
436/// the right, and a status line that explains whatever the cursor is on.
437fn 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    // ---- the page ----
478    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        // A long state reads better labelled once.
496        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    // ---- the preview ----
545    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    // Problems go first: they are what to act on, and a long preview must not hide them.
568    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    // ---- the status line: what the cursor is on ----
614    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
633/// One line about the kind of line under the cursor — the notation explains itself in place.
634fn 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}