jev.rsannotatedjev.rssource436 lines · 20.4 KB · raw

Jev making the bot's goal choice (packages/decisions's goal_choice module): whether it is on, the shared spend guards, and a scrollable history of every choice it has made - this run, and, loaded at startup from the run's own JSONL log, before it too. Click an asked row to see everything asked of it.

Plain data in, like bot.rs - this crate never links decisions or jev_http; the app fills these types from decisions::Record, decisions::Totals and jev_http::Status. serde_json::Value is the one exception to "plain data": the request Jev was actually sent is kept as real JSON rather than pre-formatted text, so the expensive part (pretty-printing it) happens only for a row that is actually expanded, not for every row on every frame - serde_json has no I/O of its own and already compiles for wasm, so this costs the crate nothing it doesn't already have to pay for its own JSON needs elsewhere in this workspace.

Everything here wraps to whatever width it is given rather than clipping or truncating (2026-09-22: the user's own screenshots at the window's default size showed row headers and the expanded JSON cut off at the panel's edge) - set_width pins the scroll area's content to the panel's own width, so every wrapped Label inside it has a FINITE target to wrap at instead of the unbounded one a ScrollArea otherwise offers its content.

25use egui::{Color32, FontId, RichText, Sense, vec2};
27use crate::bot::section;
28use crate::palette::{FAVOURITE, GOAL, GOOD, GREY, PLAIN, SECTION};

What asking Jev cost, and the request exactly as sent. Set only on a [Decision] whose choice actually went out over the network.

32pub struct Asked<'a> {
33    pub prompt: &'a serde_json::Value,
34    pub dollars: f64,
35    pub input_tokens: u64,
36    pub millis: u128,
37}

One goal choice: the row the history list shows collapsed, and everything its expanded view shows.

41pub struct Decision<'a> {
42    pub frame: u32,

This decision's kind, shown as a coloured tag - "goal_choice" today, and whatever future decision kinds share this same history log and panel (decisions::Record::kind).

46    pub kind: &'a str,

Every option in the words Jev was given, upstream's own pick first.

48    pub options: &'a [String],
49    pub probabilities: Option<&'a [f64]>,

Index into options: the one picked. 0 is upstream's own pick.

51    pub picked: usize,

A short word for the row: "asked", "reused", "one-choice", "throttled", "fell back".

54    pub how_kind: &'a str,

The fuller line for the expanded/compact view: "asked Jev (150 ms, ...)", "sampled again from Jev's answer at frame N", "upstream's pick: <why>", ... - carries the reason when nothing was asked.

58    pub how: &'a str,
59    pub asked: Option<Asked<'a>>,
60}

One bucket of the usage graph: what was spent, and asked, while the active branch's own frame counter was in [start_frame, start_frame + width) - the SAME frame numbering the scrubber shows ("frame N / M"), not a wall-clock time (dropped 2026-09-22: a 24h/7d wall-clock view was the first design, but the user asked for usage tied to the RUN, not the clock - "since the current recording started... on the same axis as the scrubber"). Plain data - decisions::Record is what the app derives this from, keeping jev_http/decisions out of this crate the same way bot.rs/replay.rs already do.

71pub struct UsagePoint {
72    pub start_frame: u64,
73    pub spend_usd: f64,
74    pub tokens: u64,
75    pub requests: u32,
76}

Jev at the bot's goal choice, header and history together.

79pub struct Jev<'a> {

"on", "off", or why it is not on.

81    pub status: &'a str,

The shared guards, every process's questions counted: questions in the last minute of the bucket's size, the hour's dollars, lifetime.

84    pub guards: &'a str,

What is stopping questions right now (a throttle that clears itself, or a cap that does not), if anything.

87    pub stopped: Option<&'a str>,

"N choices, N questions, ..." so far, this process's own totals.

89    pub totals: &'a str,

Every decision, newest first, bounded by the chooser itself (decisions::HISTORY_CAP) so a run that plays for days does not grow this panel's memory without limit - the on-disk log it was seeded from keeps every decision ever made, if more is ever needed.

94    pub history: &'a [Decision<'a>],

Whether the list shows every decision (true) or only the ones that actually asked (false, the default a caller should start with) - reused/one-choice/throttled/fell-back rows are most of a long run's history and bury the real questions when shown by default (user, 2026-09-22). Owned by the app as ordinary widget state, like the speed control's own field on App - this crate never persists anything itself.

102    pub show_all: &'a mut bool,

Usage since the current recording started (frame 0 of the active branch - research/replay-timeline.md's "a recording always starts exactly when a fresh Bot does"), bucketed across [0, usage_tip_frame]

  • the SAME span the scrubber's own bar spans - so the buckets' widths stay fixed as the playhead moves. Only decisions up to usage_playhead_frame are actually summed into them (the app's own job, since decisions::Record never crosses into this crate): the bars past that are drawn dim because there is nothing there YET, not because nothing happened.
112    pub usage: &'a [UsagePoint],
113    pub usage_tip_frame: u64,
114    pub usage_playhead_frame: u64,
115}

The usage graph: a bar per bucket (spend - the metric the dashboard's own bars use), the recording's own total (up to the playhead) top right, a playhead marker matching the scrubber's, and a hover per bar with the numbers a bar's height alone cannot show (tokens, requests). Survives no recording yet / nothing spent yet by saying so instead of drawing an empty chart.

123pub fn usage_graph(ui: &mut egui::Ui, usage: &[UsagePoint], tip_frame: u64, playhead_frame: u64) {
124    ui.horizontal(|ui| {
125        section(ui, "Usage this recording");
126        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
127            let total: f64 = usage.iter().map(|p| p.spend_usd).sum();
128            ui.label(RichText::new(format!("${total:.4}")).font(FontId::monospace(12.0)).color(GOOD));
129        });
130    });
131    if usage.is_empty() || tip_frame == 0 {
132        wrapped(ui, "no usage yet this recording", GREY);
133        return;
134    }
135    const HEIGHT: f32 = 56.0;
136    const GAP: f32 = 2.0;
137    let width = ui.available_width();
138    let bar_width = ((width - GAP * (usage.len() as f32 - 1.0)) / usage.len() as f32).max(1.0);
139    let (rect, _) = ui.allocate_exact_size(vec2(width, HEIGHT), Sense::hover());
140    let max_spend = usage.iter().map(|p| p.spend_usd).fold(0.0_f64, f64::max).max(1e-9);
141    ui.painter().rect_filled(rect, 2.0, Color32::from_gray(30));
142    for (i, point) in usage.iter().enumerate() {
143        let frac = (point.spend_usd / max_spend).clamp(0.0, 1.0) as f32;
144        let x0 = rect.min.x + i as f32 * (bar_width + GAP);
145        let bar = egui::Rect::from_min_max(
146            egui::pos2(x0, rect.max.y - HEIGHT * frac),
147            egui::pos2(x0 + bar_width, rect.max.y),
148        );
149        // Not reached by the playhead yet: dim, regardless of height (it can
150        // only be zero-height anyway, since nothing past the playhead was
151        // summed in) - a visual cue matching the scrubber's own unplayed-vs-
152        // played distinction, not a claim about a real zero.
153        let beyond_playhead = point.start_frame > playhead_frame;
154        let response = ui.interact(bar, ui.id().with(("jev-usage-bar", i)), Sense::hover());
155        let colour = if beyond_playhead {
156            Color32::from_gray(40)
157        } else if response.hovered() {
158            GOOD
159        } else {
160            GOOD.gamma_multiply(0.7)
161        };
162        ui.painter().rect_filled(bar, 1.0, colour);
163        response.on_hover_text(format!(
164            "frame {}: ${:.4}, {} tokens, {} request{}",
165            point.start_frame,
166            point.spend_usd,
167            point.tokens,
168            point.requests,
169            if point.requests == 1 { "" } else { "s" }
170        ));
171    }
172    let head_frac = (playhead_frame as f32 / tip_frame.max(1) as f32).clamp(0.0, 1.0);
173    let head_x = rect.min.x + rect.width() * head_frac;
174    ui.painter().line_segment([egui::pos2(head_x, rect.min.y), egui::pos2(head_x, rect.max.y)], egui::Stroke::new(1.0, PLAIN));
175    ui.horizontal(|ui| {
176        monospace_wrapped(ui, "frame 0", GREY, 9.0);
177        ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
178            monospace_wrapped(ui, format!("frame {tip_frame}"), GREY, 9.0);
179        });
180    });
181    let total_tokens: u64 = usage.iter().map(|p| p.tokens).sum();
182    let total_requests: u32 = usage.iter().map(|p| p.requests).sum();
183    wrapped(ui, format!("{total_tokens} tokens, {total_requests} requests"), GREY);
184}
186fn kind_tag(ui: &mut egui::Ui, kind: &str) {
187    let colour = match kind {
188        "goal_choice" => SECTION,
189        _ => PLAIN,
190    };
191    ui.label(
192        RichText::new(kind)
193            .font(FontId::monospace(9.0))
194            .color(Color32::BLACK)
195            .background_color(colour.gamma_multiply(0.9)),
196    );
197}
198
199fn how_badge(ui: &mut egui::Ui, how_kind: &str) {
200    let colour = match how_kind {
201        "asked" => GOOD,
202        "reused" => PLAIN,
203        "one-choice" => GREY,
204        "throttled" | "fell back" => FAVOURITE,
205        _ => GREY,
206    };
207    ui.label(
208        RichText::new(how_kind)
209            .font(FontId::monospace(9.0))
210            .color(Color32::BLACK)
211            .background_color(colour.gamma_multiply(0.9)),
212    );
213}

A Label that wraps at whatever width the current Ui has - never truncated, never clipped. Callers pin that width with set_width before adding any of these, since an unconstrained Ui (a ScrollArea's, chiefly) otherwise reports it as unbounded and nothing ever wraps.

219fn wrapped(ui: &mut egui::Ui, text: impl Into<String>, colour: Color32) {
220    ui.add(egui::Label::new(RichText::new(text.into()).color(colour)).wrap());
221}
223fn monospace_wrapped(ui: &mut egui::Ui, text: impl Into<String>, colour: Color32, size: f32) {
224    ui.add(egui::Label::new(RichText::new(text.into()).font(FontId::monospace(size)).color(colour)).wrap());
225}

The option with the highest probability, when there is one to have.

228fn favourite_of(probabilities: Option<&[f64]>) -> Option<usize> {
229    probabilities?
230        .iter()
231        .enumerate()
232        .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
233        .map(|(i, _)| i)
234}

One option: a bar for Jev's probability (green when it is the pick, amber when it is Jev's own favourite but sampling drew something else, plain otherwise), and the sentence wrapped beneath it - never truncated, since an EXPLORE option's sentence can run past a hundred characters once dungeon lore is appended (goal_choice::Facts::sentences).

241fn option_row(ui: &mut egui::Ui, option: &str, probability: Option<f64>, is_picked: bool, is_favourite: bool) {
242    let colour = if is_picked { GOOD } else if is_favourite { FAVOURITE } else { PLAIN };
243    ui.horizontal(|ui| {
244        let pct = probability.map_or_else(|| "  - ".to_owned(), |p| format!("{:>3.0}%", p * 100.0));
245        ui.label(RichText::new(pct).font(FontId::monospace(10.0)).color(colour));
246        const BAR_WIDTH: f32 = 70.0;
247        const BAR_HEIGHT: f32 = 9.0;
248        let (rect, _) = ui.allocate_exact_size(vec2(BAR_WIDTH, BAR_HEIGHT), Sense::hover());
249        ui.painter().rect_filled(rect, 2.0, Color32::from_gray(45));
250        if let Some(p) = probability {
251            let filled = egui::Rect::from_min_size(rect.min, vec2(rect.width() * p.clamp(0.0, 1.0) as f32, rect.height()));
252            ui.painter().rect_filled(filled, 2.0, colour.gamma_multiply(0.65));
253        }
254        let marker = if is_picked { ">" } else if is_favourite { "~" } else { " " };
255        ui.label(RichText::new(marker).font(FontId::monospace(10.0)).color(colour));
256    });
257    wrapped(ui, option, colour);
258}

An "asked" row: a card with the header (frame, kind tag, badge, the pick's own probability - all on one wrapped line), a bar per option, and, collapsed by default, the exact request sent with a copy button.

263fn asked_card(ui: &mut egui::Ui, d: &Decision) {
264    egui::Frame::group(ui.style()).show(ui, |ui| {
265        ui.set_width(ui.available_width());
266        let picked_pct = d.probabilities.and_then(|ps| ps.get(d.picked)).copied();
267        ui.horizontal_wrapped(|ui| {
268            ui.label(RichText::new(format!("{}", d.frame)).font(FontId::monospace(11.0)).color(PLAIN));
269            kind_tag(ui, d.kind);
270            how_badge(ui, d.how_kind);
271            if let Some(p) = picked_pct {
272                ui.label(RichText::new(format!("{:.0}%", p * 100.0)).font(FontId::monospace(11.0)).color(GOOD));
273            }
274        });
275        let favourite = favourite_of(d.probabilities);
276        for (i, option) in d.options.iter().enumerate() {
277            let probability = d.probabilities.and_then(|ps| ps.get(i)).copied();
278            option_row(ui, option, probability, i == d.picked, Some(i) == favourite && i != d.picked);
279        }
280        if let Some(asked) = &d.asked {
281            egui::CollapsingHeader::new(
282                RichText::new(format!("{} ms · {} tokens · ${:.6}", asked.millis, asked.input_tokens, asked.dollars))
283                    .font(FontId::monospace(10.0))
284                    .color(GREY),
285            )
286            .id_salt(("jev-asked", d.frame))
287            .show(ui, |ui| {
288                ui.set_width(ui.available_width());
289                let pretty =
290                    serde_json::to_string_pretty(asked.prompt).unwrap_or_else(|_| asked.prompt.to_string());
291                if ui.button("Copy request JSON").clicked() {
292                    ui.ctx().copy_text(pretty.clone());
293                }
294                section(ui, "Request sent");
295                // A read-only-in-spirit `TextEdit` rather than a `Label`: it
296                // wraps AND lets the user select and copy any part of it by
297                // hand, which a `Label` never allows - the whole point raised
298                // against the old plain-text view (user, 2026-09-22). Edits
299                // the user makes land in this per-frame local copy and are
300                // gone next frame, never written back anywhere.
301                let mut shown = pretty.clone();
302                ui.add(
303                    egui::TextEdit::multiline(&mut shown)
304                        .font(FontId::monospace(10.0))
305                        .desired_width(ui.available_width())
306                        .code_editor(),
307                );
308            });
309        }
310    });
311    ui.add_space(4.0);
312}

A row for anything that was NOT asked - reused, one-choice, throttled, fell back. Most of a long run's history is these, so they are one dim wrapped line each with no card, no bars and no expand arrow: there is nothing more to show than how already says.

318fn compact_row(ui: &mut egui::Ui, d: &Decision) {
319    let option = d.options.get(d.picked).map_or("(no option)", String::as_str);
320    ui.horizontal_wrapped(|ui| {
321        ui.label(RichText::new(format!("{}", d.frame)).font(FontId::monospace(10.0)).color(GREY));
322        how_badge(ui, d.how_kind);
323        monospace_wrapped(ui, option, GREY, 10.0);
324    });
325}

The header (on/off, guards, totals) and the scrollable history below it.

328pub fn show(ui: &mut egui::Ui, jev: &mut Jev) {
329    ui.heading("Jev");
330    ui.label(RichText::new(format!("goal choice: {}", jev.status)).color(GOAL));
331    if !jev.guards.is_empty() {
332        wrapped(ui, jev.guards, GREY);
333    }
334    if let Some(stopped) = jev.stopped {
335        wrapped(ui, stopped, GOAL);
336    }
337    if !jev.totals.is_empty() {
338        wrapped(ui, jev.totals, GREY);
339    }
340    usage_graph(ui, jev.usage, jev.usage_tip_frame, jev.usage_playhead_frame);
341    section(ui, "History");
342    ui.checkbox(jev.show_all, "show reused / one-choice / throttled too");
343    egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
344        // Pins this scroll area's content to the panel's own width - see
345        // this module's doc comment for why every wrapped `Label` below
346        // needs this to actually wrap instead of running off the edge.
347        ui.set_width(ui.available_width());
348        if jev.history.is_empty() {
349            ui.label(RichText::new("no choices yet").color(GREY));
350            return;
351        }
352        let mut shown = 0usize;
353        for decision in jev.history {
354            if decision.how_kind == "asked" {
355                shown += 1;
356                asked_card(ui, decision);
357            } else if *jev.show_all {
358                shown += 1;
359                compact_row(ui, decision);
360            }
361        }
362        if shown == 0 {
363            wrapped(ui, "no asked choices yet - check the box above to see reused/one-choice/throttled rows", GREY);
364        }
365    });
366}
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    fn decision<'a>(frame: u32, options: &'a [String], probabilities: Option<&'a [f64]>, picked: usize) -> Decision<'a> {
373        Decision {
374            frame,
375            kind: "goal_choice",
376            options,
377            probabilities,
378            picked,
379            how_kind: "asked",
380            how: "asked Jev (150 ms, 500 tokens, $0.000021)",
381            asked: None,
382        }
383    }
384
385    #[test]
386    fn the_favourite_is_the_highest_probability_option() {
387        assert_eq!(favourite_of(Some(&[0.1, 0.7, 0.2])), Some(1));
388        assert_eq!(favourite_of(Some(&[])), None);
389        assert_eq!(favourite_of(None), None);
390    }
391
392    #[test]
393    fn a_row_survives_no_probabilities_and_an_out_of_range_pick() {
394        // `decisions::How::Reused`/`Default`/`Throttled` never sample, so
395        // `probabilities` is `None`; a malformed pick must not panic the
396        // whole panel over one bad row (`../CLAUDE.md`'s "every panel here
397        // must survive its value being absent or empty").
398        let options = ["open the chest".to_owned()];
399        let d = decision(1, &options, None, 9);
400        assert!(d.options.get(d.picked).is_none(), "the fixture itself is out of range");
401        // `option_row`/`compact_row` read `options.get(picked)` defensively
402        // (`.map_or`), never index directly - this asserts the fixture is
403        // actually testing that path rather than happening to be in range.
404    }
405
406    #[test]
407    fn a_long_option_wraps_within_a_pinned_width_instead_of_running_past_it() {
408        // The bug (user, 2026-09-22, from real screenshots at the window's
409        // DEFAULT size): a `Label` inside an unconstrained `Ui` (a
410        // `ScrollArea`'s, before `set_width` pinned it) reports its wrap
411        // width as effectively infinite, so a long sentence never actually
412        // wraps - it just runs past the visible panel edge. This drives
413        // egui headless (no window, no backend) and checks the produced
414        // galley directly: at a narrow pinned width it must break into
415        // several rows and none of them may exceed that width; at an
416        // effectively unbounded width the same text fits on one row - which
417        // is exactly the failure mode being guarded against.
418        let long = "Go through the north door in the north side of an unnamed room, north-east of Link, reached by leaving this screen through its stairs down and crossing 15 screens, to somewhere never visited. It is about 2357 steps away. (Eastern Palace vanilla holds the Pendant of Courage, guarded by Armos Knights.)";
419        let ctx = egui::Context::default();
420        let narrow = ctx.run_ui(egui::RawInput::default(), |ui| {
421            ui.set_width(240.0);
422            wrapped(ui, long, PLAIN);
423        });
424        let _ = narrow; // FullOutput; the assertion is against fonts() below.
425        let rows_at = |width: f32| {
426            ctx.fonts_mut(|fonts| {
427                let job = egui::text::LayoutJob::simple(long.to_owned(), FontId::proportional(12.0), PLAIN, width);
428                fonts.layout_job(job).rows.len()
429            })
430        };
431        let narrow_rows = rows_at(240.0);
432        let wide_rows = rows_at(100_000.0);
433        assert!(narrow_rows > 1, "a long sentence at 240pt must wrap to more than one row, got {narrow_rows}");
434        assert_eq!(wide_rows, 1, "the same text at an effectively unbounded width must not wrap at all, got {wide_rows}");
435    }
436}