1//! Jev making the bot's goal choice (`packages/decisions`'s `goal_choice` 2//! module): whether it is on, the shared spend guards, and a scrollable 3//! history of every choice it has made - this run, and, loaded at startup 4//! from the run's own JSONL log, before it too. Click an asked row to see 5//! everything asked of it. 6//! 7//! Plain data in, like `bot.rs` - this crate never links `decisions` or 8//! `jev_http`; the app fills these types from `decisions::Record`, 9//! `decisions::Totals` and `jev_http::Status`. `serde_json::Value` is the one 10//! exception to "plain data": the request Jev was actually sent is kept as 11//! real JSON rather than pre-formatted text, so the expensive part 12//! (pretty-printing it) happens only for a row that is actually expanded, 13//! not for every row on every frame - `serde_json` has no I/O of its own and 14//! already compiles for wasm, so this costs the crate nothing it doesn't 15//! already have to pay for its own JSON needs elsewhere in this workspace. 16//! 17//! Everything here wraps to whatever width it is given rather than clipping 18//! or truncating (2026-09-22: the user's own screenshots at the window's 19//! default size showed row headers and the expanded JSON cut off at the 20//! panel's edge) - `set_width` pins the scroll area's content to the 21//! panel's own width, so every wrapped `Label` inside it has a FINITE 22//! target to wrap at instead of the unbounded one a `ScrollArea` otherwise 23//! offers its content. 24 25use egui::{Color32, FontId, RichText, Sense, vec2}; 26 27use crate::bot::section; 28use crate::palette::{FAVOURITE, GOAL, GOOD, GREY, PLAIN, SECTION}; 29 30/// What asking Jev cost, and the request exactly as sent. Set only on a 31/// [`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} 38 39/// One goal choice: the row the history list shows collapsed, and 40/// everything its expanded view shows. 41pub struct Decision<'a> { 42 pub frame: u32, 43 /// This decision's kind, shown as a coloured tag - "goal_choice" today, 44 /// and whatever future decision kinds share this same history log and 45 /// panel (`decisions::Record::kind`). 46 pub kind: &'a str, 47 /// Every option in the words Jev was given, upstream's own pick first. 48 pub options: &'a [String], 49 pub probabilities: Option<&'a [f64]>, 50 /// Index into `options`: the one picked. 0 is upstream's own pick. 51 pub picked: usize, 52 /// A short word for the row: "asked", "reused", "one-choice", 53 /// "throttled", "fell back". 54 pub how_kind: &'a str, 55 /// The fuller line for the expanded/compact view: "asked Jev (150 ms, 56 /// ...)", "sampled again from Jev's answer at frame N", "upstream's 57 /// pick: <why>", ... - carries the reason when nothing was asked. 58 pub how: &'a str, 59 pub asked: Option<Asked<'a>>, 60} 61 62/// One bucket of the usage graph: what was spent, and asked, while the 63/// active branch's own frame counter was in `[start_frame, start_frame + 64/// width)` - the SAME frame numbering the scrubber shows ("frame N / M"), 65/// not a wall-clock time (dropped 2026-09-22: a 24h/7d wall-clock view was 66/// the first design, but the user asked for usage tied to the RUN, not the 67/// clock - "since the current recording started... on the same axis as the 68/// scrubber"). Plain data - `decisions::Record` is what the app derives this 69/// from, keeping `jev_http`/`decisions` out of this crate the same way 70/// `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} 77 78/// Jev at the bot's goal choice, header and history together. 79pub struct Jev<'a> { 80 /// "on", "off", or why it is not on. 81 pub status: &'a str, 82 /// The shared guards, every process's questions counted: questions in 83 /// the last minute of the bucket's size, the hour's dollars, lifetime. 84 pub guards: &'a str, 85 /// What is stopping questions right now (a throttle that clears itself, 86 /// or a cap that does not), if anything. 87 pub stopped: Option<&'a str>, 88 /// "N choices, N questions, ..." so far, this process's own totals. 89 pub totals: &'a str, 90 /// Every decision, newest first, bounded by the chooser itself 91 /// (`decisions::HISTORY_CAP`) so a run that plays for days does not 92 /// grow this panel's memory without limit - the on-disk log it was 93 /// seeded from keeps every decision ever made, if more is ever needed. 94 pub history: &'a [Decision<'a>], 95 /// Whether the list shows every decision (`true`) or only the ones that 96 /// actually asked (`false`, the default a caller should start with) - 97 /// reused/one-choice/throttled/fell-back rows are most of a long run's 98 /// history and bury the real questions when shown by default (user, 99 /// 2026-09-22). Owned by the app as ordinary widget state, like the 100 /// speed control's own field on `App` - this crate never persists 101 /// anything itself. 102 pub show_all: &'a mut bool, 103 /// Usage since the current recording started (frame 0 of the active 104 /// branch - `research/replay-timeline.md`'s "a recording always starts 105 /// exactly when a fresh Bot does"), bucketed across `[0, usage_tip_frame]` 106 /// - the SAME span the scrubber's own bar spans - so the buckets' widths 107 /// stay fixed as the playhead moves. Only decisions up to 108 /// `usage_playhead_frame` are actually summed into them (the app's own 109 /// job, since `decisions::Record` never crosses into this crate): the 110 /// bars past that are drawn dim because there is nothing there YET, not 111 /// because nothing happened. 112 pub usage: &'a [UsagePoint], 113 pub usage_tip_frame: u64, 114 pub usage_playhead_frame: u64, 115} 116 117/// The usage graph: a bar per bucket (spend - the metric the dashboard's own 118/// bars use), the recording's own total (up to the playhead) top right, a 119/// playhead marker matching the scrubber's, and a hover per bar with the 120/// numbers a bar's height alone cannot show (tokens, requests). Survives no 121/// recording yet / nothing spent yet by saying so instead of drawing an 122/// 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} 185 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} 214 215/// A `Label` that wraps at whatever width the current `Ui` has - never 216/// truncated, never clipped. Callers pin that width with `set_width` before 217/// adding any of these, since an unconstrained `Ui` (a `ScrollArea`'s, 218/// 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} 222 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} 226 227/// 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} 235 236/// One option: a bar for Jev's probability (green when it is the pick, 237/// amber when it is Jev's own favourite but sampling drew something else, 238/// plain otherwise), and the sentence wrapped beneath it - never truncated, 239/// since an EXPLORE option's sentence can run past a hundred characters 240/// 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} 259 260/// An "asked" row: a card with the header (frame, kind tag, badge, the 261/// pick's own probability - all on one wrapped line), a bar per option, and, 262/// 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} 313 314/// A row for anything that was NOT asked - reused, one-choice, throttled, 315/// fell back. Most of a long run's history is these, so they are one dim 316/// wrapped line each with no card, no bars and no expand arrow: there is 317/// 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} 326 327/// 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} 367 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}