1use std::io;
2use std::time::Instant;
3
4use crate::event::Event;
5use crate::tui::theme::Theme;
6use crossterm::{
7 event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers},
8 execute,
9 terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
10};
11use ratatui::{
12 Frame,
13 layout::{Constraint, Direction, Layout, Rect},
14 style::{Color, Modifier, Style},
15 text::{Line, Span, Text},
16 widgets::{Block, Borders, Paragraph},
17};
18use tokio::sync::mpsc;
19
20pub mod formatters;
21pub mod renderer;
22pub mod theme;
23
24fn ensure_utf8_console() {
35 #[cfg(windows)]
36 {
37 unsafe extern "system" {
41 fn SetConsoleOutputCP(wCodePageID: u32) -> i32;
42 fn SetConsoleCP(wCodePageID: u32) -> i32;
43 }
44 const CP_UTF8: u32 = 65001;
45 unsafe {
46 let _ = SetConsoleOutputCP(CP_UTF8);
47 let _ = SetConsoleCP(CP_UTF8);
48 }
49 }
50}
51pub mod ansi_bridge;
52
53type CrosstermTerminal = ratatui::Terminal<ratatui::backend::CrosstermBackend<io::Stdout>>;
54
55#[derive(Debug, Clone)]
56struct LogLine {
57 text: String,
58 style: LogStyle,
59 indent: u16,
60 group: Option<usize>,
62 header_for: Option<usize>,
64}
65
66#[derive(Debug, Clone)]
68struct TaskGroup {
69 title: String,
70 collapsed: bool,
71 style: LogStyle,
72}
73
74#[derive(Debug, Clone, Copy, PartialEq)]
75enum LogStyle {
76 Normal,
77 Dim,
78 Brand,
79 Agent,
80 Planner,
81 Verifier,
82 Rem,
83 Steel,
84 Gold,
85 Prompt,
86 Cmd,
87 Ok,
88 Warn,
89 Err,
90 Accent,
91}
92
93impl LogStyle {
94 fn color(&self, theme: &Theme) -> Color {
95 match self {
96 LogStyle::Normal => theme.fg,
97 LogStyle::Dim => theme.dim,
98 LogStyle::Brand => theme.brand,
99 LogStyle::Agent => theme.agent,
100 LogStyle::Planner => theme.planner,
101 LogStyle::Verifier => theme.verifier,
102 LogStyle::Rem => theme.rem,
103 LogStyle::Steel => theme.steel,
104 LogStyle::Gold => theme.gold,
105 LogStyle::Prompt => theme.brand,
106 LogStyle::Cmd => theme.fg,
107 LogStyle::Ok => theme.add,
108 LogStyle::Warn => theme.verifier,
109 LogStyle::Err => theme.rem,
110 LogStyle::Accent => theme.brand,
111 }
112 }
113}
114
115const SLASH_COMMANDS: &[&str] = &[
116 "/help",
117 "/plan",
118 "/permissions",
119 "/memory",
120 "/compact",
121 "/model",
122 "/agents",
123 "/sessions",
124 "/export",
125 "/run",
126 "/chat",
127 "/swarm",
128 "/agent",
129 "/skills",
130 "/checkpoint",
131 "/rewind",
132 "/replay",
133 "/auth",
134 "/clear",
135 "/collapse",
136 "/expand",
137 "/exit",
138];
139
140const HISTORY_MAX: usize = 100;
141
142#[derive(Debug, Clone)]
145struct LaneState {
146 status: String,
148 note: String,
150 model: String,
152}
153
154impl Default for LaneState {
155 fn default() -> Self {
156 Self {
157 status: "Idle".into(),
158 note: "".into(),
159 model: "".into(),
160 }
161 }
162}
163
164#[derive(Debug, Clone, Default)]
165struct SwarmLanesState {
166 planner: LaneState,
167 coder: LaneState,
168 verifier: LaneState,
169 started_at_frame: u64,
171}
172
173#[derive(Debug, Clone)]
176enum DiffLineKind {
177 Context,
178 Plus,
179 Minus,
180 Hunk,
181}
182
183#[derive(Debug, Clone)]
184struct DiffLineEntry {
185 kind: DiffLineKind,
186 text: String,
187}
188
189#[derive(Debug, Clone)]
190struct DiffEntry {
191 file: String,
192 plus: u32,
193 minus: u32,
194 lines: Vec<DiffLineEntry>,
195 applied: bool,
196}
197
198fn parse_diff_patch(patch: &str) -> Vec<DiffLineEntry> {
199 let mut out = Vec::new();
200 for line in patch.lines().take(40) {
201 let kind = if line.starts_with("+++") || line.starts_with("---") {
202 DiffLineKind::Context
203 } else if line.starts_with("@@") {
204 DiffLineKind::Hunk
205 } else if line.starts_with('+') {
206 DiffLineKind::Plus
207 } else if line.starts_with('-') {
208 DiffLineKind::Minus
209 } else {
210 DiffLineKind::Context
211 };
212 out.push(DiffLineEntry {
213 kind,
214 text: line.to_string(),
215 });
216 }
217 out
218}
219
220fn truncate_for_width(text: &str, width: usize) -> String {
221 if width == 0 {
222 return String::new();
223 }
224 let mut out = String::new();
225 for ch in text.chars().take(width) {
226 out.push(ch);
227 }
228 if text.chars().count() > width && width > 1 {
229 out.pop();
230 out.push('…');
231 }
232 out
233}
234
235fn syntax_spans(text: &str, theme: &Theme, base: Color) -> Vec<Span<'static>> {
236 const KEYWORDS: &[&str] = &[
237 "fn", "pub", "if", "else", "return", "let", "mut", "const", "struct", "impl", "trait",
238 "use", "as", "match",
239 ];
240 let violet = Color::Rgb(0xb4, 0x8e, 0xff);
241 let mut spans = Vec::new();
242 let mut buf = String::new();
243 let chars = text.chars();
244 let mut in_string = false;
245
246 let flush_word = |word: &mut String, spans: &mut Vec<Span<'static>>, next_is_call: bool| {
247 if word.is_empty() {
248 return;
249 }
250 let style = if KEYWORDS.contains(&word.as_str()) {
251 Style::default().fg(violet).add_modifier(Modifier::BOLD)
252 } else if next_is_call {
253 Style::default().fg(theme.gold)
254 } else {
255 Style::default().fg(base)
256 };
257 spans.push(Span::styled(std::mem::take(word), style));
258 };
259
260 for ch in chars {
261 if ch == '"' {
262 if in_string {
263 buf.push(ch);
264 spans.push(Span::styled(
265 std::mem::take(&mut buf),
266 Style::default().fg(theme.add),
267 ));
268 in_string = false;
269 } else {
270 flush_word(&mut buf, &mut spans, false);
271 buf.push(ch);
272 in_string = true;
273 }
274 continue;
275 }
276 if in_string {
277 buf.push(ch);
278 continue;
279 }
280 if ch.is_alphanumeric() || ch == '_' {
281 buf.push(ch);
282 continue;
283 }
284 let next_is_call = ch == '(';
285 flush_word(&mut buf, &mut spans, next_is_call);
286 spans.push(Span::styled(ch.to_string(), Style::default().fg(base)));
287 }
288 if in_string {
289 spans.push(Span::styled(buf, Style::default().fg(theme.add)));
290 } else {
291 flush_word(&mut buf, &mut spans, false);
292 }
293 spans
294}
295
296#[derive(Debug, Clone)]
299struct CheckpointNode {
300 id: String,
301 label: String,
302 current: bool,
303}
304
305#[derive(Debug, Clone)]
308struct Ember {
309 x: u16,
310 y: f32,
311 vy: f32,
312 amber: bool,
314 life: u32,
315 max_life: u32,
316 glyph: char,
318}
319
320#[derive(Debug, Clone)]
323struct Toast {
324 text: String,
325 age: u32,
327 max_age: u32,
329}
330
331pub struct Tui {
332 theme: Theme,
333 lines: Vec<LogLine>,
334 route: String,
335 cost_usd: f64,
336 total_tokens: u64,
337 autonomy: String,
338 input_lines: Vec<String>,
340 cursor_row: usize,
342 cursor_col: usize,
344 history: Vec<String>,
346 history_idx: Option<usize>,
348 inject_pending: bool,
350 scroll: u16,
351 frame: u64,
352 spinner_idx: usize,
353 booted: bool,
354 boot_progress: u32,
355 event_rx: Option<mpsc::UnboundedReceiver<Event>>,
356 task_tx: Option<mpsc::UnboundedSender<String>>,
357 history_path: Option<std::path::PathBuf>,
358
359 swarm_lanes: Option<SwarmLanesState>,
362 pending_diffs: std::collections::VecDeque<DiffEntry>,
364 checkpoints: Vec<CheckpointNode>,
366 embers: Vec<Ember>,
368 toast: Option<Toast>,
370 cost_flash_frames: u32,
372 last_cost: f64,
373 tok_flash_frames: u32,
375 last_tokens: u64,
376
377 groups: Vec<TaskGroup>,
380 current_group: Option<usize>,
382 focus_group: Option<usize>,
384
385 replay_events: Option<Vec<Event>>,
388 replay_idx: usize,
389 think: crate::event::ThinkStripper,
391 agent_names: Vec<String>,
393 active_agent: Option<String>,
395 agent_souls: std::collections::HashMap<String, (String, String)>,
397 term_renderer: crate::tui::renderer::TermRenderer,
399 simple: bool,
402 experience_mode: String,
403 lang: crate::humanize::Lang,
404}
405
406impl Tui {
407 pub fn new() -> Self {
408 let history_path = dirs::state_dir()
410 .or_else(dirs::data_local_dir)
411 .or_else(dirs::data_dir)
412 .map(|d| d.join("sparrow").join("tui_history.txt"));
413 let history = history_path
414 .as_ref()
415 .and_then(|p| std::fs::read_to_string(p).ok())
416 .map(|s| s.lines().map(String::from).collect())
417 .unwrap_or_default();
418
419 let theme = std::env::var("SPARROW_THEME")
421 .ok()
422 .map(|n| crate::tui::theme::by_name(&n))
423 .unwrap_or_default();
424 let mut tui = Self {
425 theme,
426 lines: Vec::new(),
427 route: "idle".into(),
428 cost_usd: 0.0,
429 total_tokens: 0,
430 autonomy: "supervised".into(),
431 input_lines: vec![String::new()],
432 cursor_row: 0,
433 cursor_col: 0,
434 history,
435 history_idx: None,
436 inject_pending: false,
437 scroll: 0,
438 frame: 0,
439 spinner_idx: 0,
440 booted: false,
441 boot_progress: 0,
442 event_rx: None,
443 task_tx: None,
444 history_path,
445 swarm_lanes: None,
446 pending_diffs: std::collections::VecDeque::new(),
447 checkpoints: Vec::new(),
448 embers: Self::spawn_embers(),
449 toast: None,
450 cost_flash_frames: 0,
451 last_cost: 0.0,
452 tok_flash_frames: 0,
453 last_tokens: 0,
454 groups: Vec::new(),
455 current_group: None,
456 focus_group: None,
457 replay_events: None,
458 replay_idx: 0,
459 think: crate::event::ThinkStripper::new(),
460 agent_names: Vec::new(),
461 active_agent: None,
462 agent_souls: std::collections::HashMap::new(),
463 term_renderer: crate::tui::renderer::TermRenderer::new(
464 crate::tui::renderer::RenderConfig::default(),
465 ),
466 simple: true,
468 experience_mode: "simple".into(),
469 lang: crate::humanize::Lang::Fr,
470 };
471 tui.show_splash();
472 tui
473 }
474
475 pub fn with_experience(mut self, simple: bool, lang: crate::humanize::Lang) -> Self {
477 self.simple = simple;
478 self.lang = lang;
479 self
480 }
481
482 pub fn with_experience_mode(mut self, mode: &str) -> Self {
483 self.experience_mode = mode.trim().to_lowercase();
484 self.lines.clear();
485 self.show_splash();
486 self
487 }
488
489 fn show_splash(&mut self) {
491 self.add_line("══════════════════════════════════════", LogStyle::Brand, 0);
492 self.add_line(
493 " 🐦 SPARROW — one cli · grows with you",
494 LogStyle::Brand,
495 0,
496 );
497 self.add_line("══════════════════════════════════════", LogStyle::Brand, 0);
498 self.add_line("", LogStyle::Cmd, 0);
499 match self.experience_mode.as_str() {
500 "builder" => {
501 self.add_line("Builder menu:", LogStyle::Cmd, 0);
502 self.add_line(" Run → sparrow run \"task\"", LogStyle::Dim, 0);
503 self.add_line(" Test → sparrow test --fix", LogStyle::Dim, 0);
504 self.add_line(
505 " Refactor → /refactor or refactor-safely",
506 LogStyle::Dim,
507 0,
508 );
509 self.add_line(" Git → sparrow commit --dry-run", LogStyle::Dim, 0);
510 self.add_line(" Debug → /tools + terminal output", LogStyle::Dim, 0);
511 self.add_line(" Replay → sparrow replay <run>", LogStyle::Dim, 0);
512 }
513 "pro" => {
514 self.add_line("Expert palette:", LogStyle::Cmd, 0);
515 self.add_line(
516 " /help → all commands and skill entries",
517 LogStyle::Dim,
518 0,
519 );
520 self.add_line(" --json → CI/headless event stream", LogStyle::Dim, 0);
521 self.add_line(" Ctrl+R → rewind to last checkpoint", LogStyle::Dim, 0);
522 self.add_line(" Ctrl+I → inject into the active run", LogStyle::Dim, 0);
523 }
524 _ => {
525 self.add_line("Try these (type in the input below):", LogStyle::Cmd, 0);
526 self.add_line(" Répare mon problème → sparrow fix", LogStyle::Dim, 0);
527 self.add_line(
528 " Explique → sparrow explique",
529 LogStyle::Dim,
530 0,
531 );
532 self.add_line(" Mes fichiers → /files", LogStyle::Dim, 0);
533 self.add_line(" Réglages → /permissions", LogStyle::Dim, 0);
534 }
535 }
536 self.add_line("", LogStyle::Cmd, 0);
537 self.add_line("# RICH RENDERING DEMO", LogStyle::Gold, 0);
539 self.add_line("", LogStyle::Cmd, 0);
540 self.add_line("Code blocks get syntax highlighting:", LogStyle::Cmd, 0);
541 self.add_line("```rust", LogStyle::Dim, 0);
542 self.add_line("fn main() {", LogStyle::Cmd, 0);
543 self.add_line(" println!(\"Hello, Sparrow!\");", LogStyle::Cmd, 0);
544 self.add_line("}", LogStyle::Cmd, 0);
545 self.add_line("```", LogStyle::Dim, 0);
546 self.add_line("", LogStyle::Cmd, 0);
547 self.add_line(
548 "Diffs are colored (additions in green, deletions in red):",
549 LogStyle::Cmd,
550 0,
551 );
552 self.add_line("--- a/src/main.rs", LogStyle::Dim, 0);
553 self.add_line("+++ b/src/main.rs", LogStyle::Dim, 0);
554 self.add_line("@@ -10,6 +10,8 @@ fn main() {", LogStyle::Dim, 0);
555 self.add_line("+ let config = load_config()?;", LogStyle::Ok, 0);
556 self.add_line(" let engine = Engine::new();", LogStyle::Cmd, 0);
557 self.add_line("- engine.run_old();", LogStyle::Err, 0);
558 self.add_line("+ engine.run_with_config(&config);", LogStyle::Ok, 0);
559 self.add_line("", LogStyle::Cmd, 0);
560 self.add_line("JSON is pretty-printed:", LogStyle::Cmd, 0);
561 self.add_line("{", LogStyle::Dim, 0);
562 self.add_line(" \"status\": \"ready\",", LogStyle::Ok, 0);
563 self.add_line(" \"version\": \"0.5.9\",", LogStyle::Gold, 0);
564 self.add_line(
565 " \"agents\": [\"nova\", \"planner\", \"coder\"]",
566 LogStyle::Cmd,
567 0,
568 );
569 self.add_line("}", LogStyle::Dim, 0);
570 self.add_line("", LogStyle::Cmd, 0);
571 self.add_line("→ Type a task or /command to begin.", LogStyle::Brand, 0);
572 }
573
574 pub fn with_replay(mut self, events: Vec<Event>) -> Self {
577 self.replay_events = Some(events);
578 self.replay_idx = 0;
579 self.booted = true; self
581 }
582
583 #[doc(hidden)]
586 pub fn force_booted(&mut self) {
587 self.booted = true;
588 }
589
590 #[doc(hidden)]
594 pub fn debug_set_boot_progress(&mut self, progress: u32) {
595 self.boot_progress = progress;
596 }
597
598 #[doc(hidden)]
605 pub fn render_to_lines(&mut self, width: u16, height: u16) -> Vec<String> {
606 let backend = ratatui::backend::TestBackend::new(width, height);
607 let mut terminal = ratatui::Terminal::new(backend).expect("test terminal");
608 terminal
609 .draw(|f| self.render(f, 0.0))
610 .expect("render must not fail");
611 let buf = terminal.backend().buffer().clone();
612 (0..height)
613 .map(|y| {
614 (0..width)
615 .map(|x| buf[(x, y)].symbol())
616 .collect::<String>()
617 .trim_end()
618 .to_string()
619 })
620 .collect()
621 }
622
623 fn rebuild_replay(&mut self) {
625 let Some(events) = self.replay_events.clone() else {
626 return;
627 };
628 self.lines.clear();
629 self.groups.clear();
630 self.current_group = None;
631 self.focus_group = None;
632 self.cost_usd = 0.0;
633 self.total_tokens = 0;
634 let upto = self.replay_idx.min(events.len());
635 for ev in events.iter().take(upto) {
636 self.push_event(ev.clone());
637 }
638 let total = events.len();
639 self.add_line(
640 &format!(
641 "── replay {}/{} (←/→ step · Home/End jump · q quit) ──",
642 upto, total
643 ),
644 LogStyle::Accent,
645 0,
646 );
647 }
648
649 fn spawn_embers() -> Vec<Ember> {
650 let glyphs = ['·', '•', '∘', '◦'];
652 (0..10u16)
653 .map(|i| Ember {
654 x: 4 + (i * 13) % 90,
655 y: 4.0 + ((i as f32) * 2.7) % 20.0,
656 vy: 0.10 + ((i as f32) * 0.037) % 0.25,
657 amber: i % 2 == 0,
658 life: ((i as u32) * 17) % 180,
659 max_life: 180 + ((i as u32) * 11) % 90,
660 glyph: glyphs[(i as usize) % glyphs.len()],
661 })
662 .collect()
663 }
664
665 fn current_input(&self) -> String {
667 self.input_lines.join("\n")
668 }
669
670 fn set_input(&mut self, s: &str) {
672 self.input_lines = s.split('\n').map(String::from).collect();
673 if self.input_lines.is_empty() {
674 self.input_lines.push(String::new());
675 }
676 self.cursor_row = self.input_lines.len() - 1;
677 self.cursor_col = self.input_lines[self.cursor_row].len();
678 }
679
680 fn push_history(&mut self, entry: &str) {
682 if entry.trim().is_empty() {
683 return;
684 }
685 if self.history.last().map(|s| s.as_str()) == Some(entry) {
686 return;
687 }
688 self.history.push(entry.to_string());
689 if self.history.len() > HISTORY_MAX {
690 let excess = self.history.len() - HISTORY_MAX;
691 self.history.drain(..excess);
692 }
693 if let Some(path) = &self.history_path {
694 if let Some(parent) = path.parent() {
695 let _ = std::fs::create_dir_all(parent);
696 }
697 let _ = std::fs::write(path, self.history.join("\n"));
698 }
699 }
700
701 fn autocomplete_matches(&self) -> Vec<&'static str> {
703 let line = &self.input_lines[0];
704 if line.starts_with('/') {
705 return SLASH_COMMANDS
706 .iter()
707 .filter(|c| c.starts_with(line.as_str()) && **c != line.as_str())
708 .copied()
709 .take(5)
710 .collect();
711 }
712 vec![]
713 }
714
715 #[doc(hidden)]
717 pub fn debug_first_line_mut(&mut self) -> &mut String {
718 if self.input_lines.is_empty() {
719 self.input_lines.push(String::new());
720 }
721 &mut self.input_lines[0]
722 }
723
724 #[doc(hidden)]
726 pub fn debug_set_cursor_col(&mut self, col: usize) {
727 self.cursor_row = 0;
728 self.cursor_col = col;
729 }
730
731 pub fn agent_matches(&self) -> Vec<String> {
734 let line = &self.input_lines[self.cursor_row];
736 let upto = line.get(..self.cursor_col).unwrap_or(line);
737 let Some(at_pos) = upto.rfind('@') else {
738 return vec![];
739 };
740 if at_pos > 0
743 && !upto[..at_pos]
744 .chars()
745 .last()
746 .map(|c| c.is_whitespace())
747 .unwrap_or(true)
748 {
749 return vec![];
750 }
751 let prefix = &upto[at_pos + 1..];
752 if prefix.contains(char::is_whitespace) {
754 return vec![];
755 }
756 self.agent_names
757 .iter()
758 .filter(|n| n.starts_with(prefix))
759 .take(5)
760 .map(|n| format!("@{}", n))
761 .collect()
762 }
763
764 pub fn with_agents(mut self, names: Vec<String>) -> Self {
766 self.agent_names = names;
767 self
768 }
769
770 pub fn toggle_agent(&mut self, name: &str) {
773 if self.active_agent.as_deref() == Some(name) {
774 self.active_agent = None;
776 } else {
777 self.active_agent = Some(name.to_string());
779 if !self.agent_souls.contains_key(name) {
780 self.cache_agent_soul(name);
781 }
782 }
783 }
784
785 fn cache_agent_soul(&mut self, name: &str) {
787 let path = dirs::config_dir()
788 .unwrap_or_default()
789 .join("sparrow")
790 .join("agents")
791 .join(format!("{}.soul.toml", name));
792 if let Ok(content) = std::fs::read_to_string(&path) {
793 let role = content
794 .lines()
795 .find(|l| l.starts_with("role"))
796 .and_then(|l| l.split('=').nth(1))
797 .map(|s| s.trim().trim_matches('"').to_string())
798 .unwrap_or_default();
799 let personality = content
800 .lines()
801 .find(|l| l.starts_with("personality"))
802 .and_then(|l| l.split('=').nth(1))
803 .map(|s| s.trim().trim_matches('"').to_string())
804 .unwrap_or_default();
805 use base64::{Engine as _, engine::general_purpose::STANDARD};
806 let b64 = STANDARD.encode(personality.as_bytes());
807 self.agent_souls.insert(name.to_string(), (role, b64));
808 }
809 }
810
811 fn agent_prefix(&self) -> String {
813 if let Some(ref name) = self.active_agent {
814 if let Some((role, b64)) = self.agent_souls.get(name) {
815 return format!("__agent:{}__{}__{}__ ", name, role, b64);
816 }
817 }
818 String::new()
819 }
820
821 pub fn with_channels(
822 mut self,
823 task_tx: mpsc::UnboundedSender<String>,
824 event_rx: mpsc::UnboundedReceiver<Event>,
825 ) -> Self {
826 self.task_tx = Some(task_tx);
827 self.event_rx = Some(event_rx);
828 self
829 }
830
831 fn format_line(&self, text: &str) -> String {
834 let trimmed = text.trim();
836
837 if trimmed.starts_with("```") || text.lines().all(|l| l.starts_with(" ") || l.is_empty())
839 {
840 return self.term_renderer.render_code(text, "");
841 }
842
843 if trimmed.contains("diff --git")
845 || trimmed.starts_with("@@")
846 || trimmed.starts_with("--- a/")
847 || trimmed.starts_with("+++ b/")
848 {
849 return self.term_renderer.render_diff(text);
850 }
851
852 if trimmed.starts_with('{') || trimmed.starts_with('[') {
854 if serde_json::from_str::<serde_json::Value>(trimmed).is_ok() {
855 return self.term_renderer.render_json(text);
856 }
857 }
858
859 if trimmed.starts_with("# ") || trimmed.starts_with("## ") || trimmed.starts_with("### ") {
861 return self.term_renderer.render_markdown(text);
862 }
863
864 text.to_string()
866 }
867
868 pub fn push_event(&mut self, event: Event) {
869 match &event {
870 Event::RunStarted { task, .. } => {
871 self.think = crate::event::ThinkStripper::new();
872 self.open_group(&format!("started: {}", task), LogStyle::Brand);
873 }
874 Event::RouteSelected { chain, .. } => {
875 self.route = chain.join(" → ");
876 self.add_line(&format!("↳ route: {}", self.route), LogStyle::Dim, 1);
877 }
878 Event::ModelSwitched {
879 from, to, reason, ..
880 } => {
881 self.route = to.clone();
882 if self.simple {
885 if let Some(line) = crate::humanize::humanize(&event, self.lang) {
886 self.add_line(&line, LogStyle::Warn, 1);
887 }
888 } else {
889 let clean = crate::event::friendly_model_switch_reason(reason);
890 let label = if crate::event::is_local_model_unavailable(reason) {
891 format!(
892 "↳ modèle local indisponible → routage modèle cloud ({})",
893 to
894 )
895 } else {
896 format!("↳ fallback: {} → {} ({})", from, to, clean)
897 };
898 self.add_line(&label, LogStyle::Warn, 1);
899 }
900 }
901 Event::ThinkingDelta { text, .. } => {
902 let visible = self.think.feed(text);
903 if !visible.is_empty() {
904 self.add_line(&visible, LogStyle::Cmd, 1);
905 }
906 }
907 Event::ReasoningDelta { .. } => {}
908 Event::ToolUseProposed { name, .. } => {
909 self.open_group(&format!("tool · {}", name), LogStyle::Steel);
910 }
911 Event::ToolOutput { blocks, .. } => {
912 for b in blocks {
913 if let crate::event::Block::Text(t) = b {
914 self.add_line(&format!(" {}", t), LogStyle::Dim, 2);
915 }
916 }
917 }
918 Event::AgentSpawned { role, model, .. } => {
919 let lanes = self.swarm_lanes.get_or_insert_with(|| SwarmLanesState {
920 started_at_frame: self.frame,
921 ..Default::default()
922 });
923 let lane = match role.as_str() {
924 "planner" => &mut lanes.planner,
925 "coder" => &mut lanes.coder,
926 "verifier" => &mut lanes.verifier,
927 _ => &mut lanes.coder,
928 };
929 lane.status = "Working".into();
930 lane.note = "spawned".into();
931 lane.model = model.clone();
932 let s = match role.as_str() {
933 "planner" => LogStyle::Planner,
934 "coder" => LogStyle::Agent,
935 "verifier" => LogStyle::Verifier,
936 _ => LogStyle::Dim,
937 };
938 self.open_group(&format!("{} ({})", role, model), s);
939 }
940 Event::AgentStatus {
941 role, note, status, ..
942 } => {
943 if let Some(lanes) = self.swarm_lanes.as_mut() {
944 let lane = match role.as_str() {
945 "planner" => &mut lanes.planner,
946 "coder" => &mut lanes.coder,
947 "verifier" => &mut lanes.verifier,
948 _ => &mut lanes.coder,
949 };
950 lane.status = format!("{:?}", status);
951 lane.note = note.clone();
952 }
953 let s = match role.as_str() {
954 "planner" => LogStyle::Planner,
955 "coder" => LogStyle::Agent,
956 "verifier" => LogStyle::Verifier,
957 _ => LogStyle::Dim,
958 };
959 let icon = match status {
960 crate::event::AgentStatus::Done => "✓",
961 crate::event::AgentStatus::Working => "●",
962 crate::event::AgentStatus::Thinking => "○",
963 crate::event::AgentStatus::Error => "✗",
964 _ => "◌",
965 };
966 self.add_line(&format!("{} {} — {}", icon, role, note), s, 1);
967 }
968 Event::CheckpointCreated { id, label, .. } => {
969 for node in &mut self.checkpoints {
970 node.current = false;
971 }
972 self.checkpoints.push(CheckpointNode {
973 id: id.0.clone(),
974 label: label.clone(),
975 current: true,
976 });
977 self.add_line(&format!("● checkpoint: {}", label), LogStyle::Gold, 0)
978 }
979 Event::SkillLearned { name, .. } => {
980 self.toast = Some(Toast {
981 text: format!("✦ skill learned · {}", name),
982 age: 0,
983 max_age: 90,
984 });
985 self.add_line(&format!("✦ skill learned · {}", name), LogStyle::Agent, 0)
986 }
987 Event::CostUpdate { usd, .. } => {
988 if *usd > self.last_cost {
989 self.cost_flash_frames = 12;
990 }
991 self.last_cost = *usd;
992 self.cost_usd = *usd;
993 }
994 Event::TokenUsage { input, output, .. } => {
995 self.total_tokens += input + output;
996 if self.total_tokens > self.last_tokens {
997 self.tok_flash_frames = 12;
998 }
999 self.last_tokens = self.total_tokens;
1000 }
1001 Event::TokenUsageEstimated { input, output, .. } => {
1002 self.total_tokens += input + output;
1003 if self.total_tokens > self.last_tokens {
1004 self.tok_flash_frames = 12;
1005 }
1006 self.last_tokens = self.total_tokens;
1007 }
1008 Event::AutonomyChanged { level, .. } => {
1009 self.autonomy = format!("{:?}", level).to_lowercase()
1010 }
1011 Event::DiffProposed {
1012 file,
1013 patch,
1014 plus,
1015 minus,
1016 ..
1017 } => {
1018 if self.pending_diffs.len() >= 3 {
1019 self.pending_diffs.pop_front();
1020 }
1021 self.pending_diffs.push_back(DiffEntry {
1022 file: file.clone(),
1023 plus: *plus,
1024 minus: *minus,
1025 lines: parse_diff_patch(patch),
1026 applied: false,
1027 });
1028 self.add_line(
1029 &format!("◇ {} +{} / -{} · proposed", file, plus, minus),
1030 LogStyle::Dim,
1031 0,
1032 )
1033 }
1034 Event::DiffApplied { file, .. } => {
1035 if let Some(entry) = self.pending_diffs.iter_mut().find(|d| d.file == *file) {
1036 entry.applied = true;
1037 }
1038 while self.pending_diffs.front().is_some_and(|d| d.applied) {
1039 self.pending_diffs.pop_front();
1040 }
1041 }
1042 Event::TestResult {
1043 passed,
1044 failed,
1045 detail,
1046 ..
1047 } => {
1048 if *failed > 0 {
1049 self.add_line(
1050 &format!("⚠ tests {} passed · {} failed", passed, failed),
1051 LogStyle::Warn,
1052 1,
1053 );
1054 for line in detail.lines() {
1055 self.add_line(&format!(" {}", line), LogStyle::Rem, 2);
1056 }
1057 } else {
1058 self.add_line(
1059 &format!("✓ tests {} passed · no regressions", passed),
1060 LogStyle::Ok,
1061 1,
1062 );
1063 }
1064 }
1065 Event::RunFinished { outcome, .. } => {
1066 let tail = self.think.flush();
1068 if !tail.trim().is_empty() {
1069 self.add_line(&tail, LogStyle::Cmd, 1);
1070 }
1071 self.close_group();
1072 if self.simple {
1073 if let Some(line) = crate::humanize::humanize(&event, self.lang) {
1076 self.add_line(&line, LogStyle::Ok, 0);
1077 }
1078 let usd = outcome.cost_usd;
1079 let cost_line = if usd <= 0.0 {
1080 "C'était gratuit.".to_string()
1081 } else if usd < 0.01 {
1082 "Coût : moins d'un centime.".to_string()
1083 } else {
1084 format!("Coût : environ {:.0} centimes.", usd * 100.0)
1085 };
1086 self.add_line(&cost_line, LogStyle::Dim, 1);
1087 } else {
1088 self.add_line(
1089 &format!(
1090 "✓ done status: {} cost: ${:.4}",
1091 outcome.status, outcome.cost_usd
1092 ),
1093 LogStyle::Ok,
1094 0,
1095 );
1096 if outcome.tokens.input > 0 || outcome.tokens.output > 0 {
1098 let comparison =
1099 crate::cost::format_comparison(outcome.cost_usd, &outcome.tokens);
1100 for line in comparison.lines().skip(1) {
1101 if !line.is_empty() && !line.starts_with("──") {
1103 let style = if line.contains("Sparrow") {
1104 LogStyle::Ok
1105 } else if line.contains("💡") {
1106 LogStyle::Warn
1107 } else {
1108 LogStyle::Rem
1109 };
1110 self.add_line(line, style, 1);
1111 }
1112 }
1113 }
1114 }
1115 }
1116 Event::Error { message, .. } => {
1117 if !crate::event::is_local_model_unavailable(message) {
1118 self.add_line(message, LogStyle::Err, 0);
1119 }
1120 }
1121 Event::UpdateAvailable {
1122 current,
1123 latest,
1124 install_cmd,
1125 ..
1126 } => {
1127 self.add_line(
1128 &format!(
1129 "📦 Sparrow v{} available (current: v{}). Run: {}",
1130 latest, current, install_cmd
1131 ),
1132 LogStyle::Warn,
1133 0,
1134 );
1135 }
1136 _ => {}
1137 }
1138 }
1139
1140 fn add_line(&mut self, text: &str, style: LogStyle, indent: u16) {
1141 let group = self.current_group;
1142 for line in text.lines() {
1143 self.lines.push(LogLine {
1144 text: line.to_string(),
1145 style,
1146 indent,
1147 group,
1148 header_for: None,
1149 });
1150 }
1151 }
1152
1153 fn open_group(&mut self, title: &str, style: LogStyle) {
1155 let id = self.groups.len();
1156 self.groups.push(TaskGroup {
1157 title: title.to_string(),
1158 collapsed: false,
1159 style,
1160 });
1161 self.lines.push(LogLine {
1162 text: title.to_string(),
1163 style,
1164 indent: 0,
1165 group: None,
1166 header_for: Some(id),
1167 });
1168 self.current_group = Some(id);
1169 self.focus_group = Some(id);
1170 }
1171
1172 fn close_group(&mut self) {
1174 self.current_group = None;
1175 }
1176
1177 fn group_child_count(&self, id: usize) -> usize {
1179 self.lines.iter().filter(|l| l.group == Some(id)).count()
1180 }
1181
1182 fn focus_group_step(&mut self, forward: bool) {
1184 if self.groups.is_empty() {
1185 return;
1186 }
1187 let last = self.groups.len() - 1;
1188 self.focus_group = Some(match self.focus_group {
1189 None => last,
1190 Some(i) if forward => (i + 1).min(last),
1191 Some(i) => i.saturating_sub(1),
1192 });
1193 }
1194
1195 fn toggle_group(&mut self) {
1197 match self.focus_group {
1198 Some(i) if i < self.groups.len() => {
1199 self.groups[i].collapsed = !self.groups[i].collapsed;
1200 }
1201 _ => {
1202 let any_open = self.groups.iter().any(|g| !g.collapsed);
1203 for g in &mut self.groups {
1204 g.collapsed = any_open;
1205 }
1206 }
1207 }
1208 }
1209
1210 fn boot(&mut self) {
1211 self.add_line(
1212 concat!(
1213 "SPARROW v",
1214 env!("CARGO_PKG_VERSION"),
1215 " — one cli · grows with you"
1216 ),
1217 LogStyle::Dim,
1218 0,
1219 );
1220 self.add_line("", LogStyle::Normal, 0);
1221
1222 #[cfg(target_os = "linux")]
1225 let sandbox_line = "local-hardened · namespaces + path boundary";
1226 #[cfg(not(target_os = "linux"))]
1227 let sandbox_line = "path-boundary enforcement (namespaces are Linux-only)";
1228
1229 let boot = [
1230 (
1231 "router ",
1232 "model routing + fallback chain",
1233 LogStyle::Planner,
1234 ),
1235 (
1236 "surfaces",
1237 "cli · tui · webview · gateway",
1238 LogStyle::Planner,
1239 ),
1240 ("sandbox ", sandbox_line, LogStyle::Ok),
1241 (
1242 "skills ",
1243 "library indexed · self-improving",
1244 LogStyle::Accent,
1245 ),
1246 (
1247 "memory ",
1248 "sqlite · bounded docs · session search",
1249 LogStyle::Ok,
1250 ),
1251 (
1252 "autonomy",
1253 "dial: supervised → trusted → autonomous",
1254 LogStyle::Accent,
1255 ),
1256 ];
1257 for (k, v, s) in &boot {
1258 self.add_line(&format!("{} {}", k, v), *s, 1);
1259 }
1260 self.add_line("✓ ready one binary. no dependencies.", LogStyle::Ok, 0);
1261 self.add_line("", LogStyle::Normal, 0);
1262 self.booted = true;
1263 }
1264
1265 pub fn run(&mut self) -> io::Result<()> {
1266 ensure_utf8_console();
1272 enable_raw_mode()?;
1273 let mut stdout = io::stdout();
1274 execute!(stdout, EnterAlternateScreen)?;
1275 let backend = ratatui::backend::CrosstermBackend::new(stdout);
1276 let mut terminal = ratatui::Terminal::new(backend)?;
1277 terminal.clear()?;
1281 let result = self.main_loop(&mut terminal);
1282 disable_raw_mode()?;
1283 execute!(io::stdout(), LeaveAlternateScreen)?;
1284 result
1285 }
1286
1287 fn main_loop(&mut self, terminal: &mut CrosstermTerminal) -> io::Result<()> {
1288 let start = Instant::now();
1289 if self.replay_events.is_some() {
1290 self.rebuild_replay();
1291 }
1292 loop {
1293 self.drain_engine_events();
1294 self.frame += 1;
1295 self.spinner_idx = (self.spinner_idx + 1) % 10;
1296 self.tick_visuals();
1297 terminal.draw(|f| self.render(f, start.elapsed().as_secs_f64()))?;
1298 if event::poll(std::time::Duration::from_millis(50))? {
1299 if let TermEvent::Key(key) = event::read()? {
1300 if key.kind != KeyEventKind::Press {
1301 continue;
1302 }
1303 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1304 let shift = key.modifiers.contains(KeyModifiers::SHIFT);
1305 match key.code {
1306 KeyCode::Esc => break,
1307 KeyCode::Char('c') if ctrl => break,
1308
1309 KeyCode::Char('q') if self.replay_events.is_some() => break,
1311 KeyCode::Left if self.replay_events.is_some() => {
1312 self.replay_idx = self.replay_idx.saturating_sub(1);
1313 self.rebuild_replay();
1314 }
1315 KeyCode::Right if self.replay_events.is_some() => {
1316 let max = self.replay_events.as_ref().map(|e| e.len()).unwrap_or(0);
1317 self.replay_idx = (self.replay_idx + 1).min(max);
1318 self.rebuild_replay();
1319 }
1320 KeyCode::Home if self.replay_events.is_some() => {
1321 self.replay_idx = 0;
1322 self.rebuild_replay();
1323 }
1324 KeyCode::End if self.replay_events.is_some() => {
1325 self.replay_idx =
1326 self.replay_events.as_ref().map(|e| e.len()).unwrap_or(0);
1327 self.rebuild_replay();
1328 }
1329
1330 KeyCode::Char('l') if ctrl => {
1332 self.lines.clear();
1333 }
1334 KeyCode::Char('i') if ctrl => {
1336 self.inject_pending = true;
1337 self.add_line(
1338 "[inject] next message will be sent to the running agent",
1339 LogStyle::Warn,
1340 0,
1341 );
1342 }
1343
1344 KeyCode::Up if ctrl => self.focus_group_step(false),
1347 KeyCode::Down if ctrl => self.focus_group_step(true),
1348 KeyCode::Char('o') if ctrl => self.toggle_group(),
1349
1350 KeyCode::Up if self.cursor_row == 0 && !self.history.is_empty() => {
1352 let new_idx = match self.history_idx {
1353 None => self.history.len() - 1,
1354 Some(0) => 0,
1355 Some(i) => i - 1,
1356 };
1357 self.history_idx = Some(new_idx);
1358 let entry = self.history[new_idx].clone();
1359 self.set_input(&entry);
1360 }
1361 KeyCode::Down if self.cursor_row == self.input_lines.len() - 1 => {
1362 match self.history_idx {
1363 Some(i) if i + 1 < self.history.len() => {
1364 self.history_idx = Some(i + 1);
1365 let entry = self.history[i + 1].clone();
1366 self.set_input(&entry);
1367 }
1368 Some(_) => {
1369 self.history_idx = None;
1370 self.set_input("");
1371 }
1372 None => {}
1373 }
1374 }
1375
1376 KeyCode::PageUp => self.scroll = self.scroll.saturating_add(10),
1378 KeyCode::PageDown => self.scroll = self.scroll.saturating_sub(10),
1379 KeyCode::Home => self.scroll = 0,
1380 KeyCode::End => self.scroll = u16::MAX,
1381
1382 KeyCode::Tab => {
1384 let line = &self.input_lines[0];
1385 if let Some(rest) = line.strip_prefix('@') {
1387 let name = &rest.trim().to_string();
1388 if !name.is_empty() && self.agent_names.contains(name) {
1389 self.toggle_agent(name);
1390 self.input_lines = vec![String::new()];
1391 self.cursor_row = 0;
1392 self.cursor_col = 0;
1393 }
1394 } else {
1395 let matches = self.autocomplete_matches();
1396 if let Some(first) = matches.first() {
1397 self.input_lines = vec![first.to_string()];
1398 self.cursor_row = 0;
1399 self.cursor_col = first.len();
1400 }
1401 }
1402 }
1403
1404 KeyCode::Backspace => {
1406 if self.cursor_col > 0 {
1407 let line = &mut self.input_lines[self.cursor_row];
1408 let new_col = line[..self.cursor_col]
1409 .char_indices()
1410 .last()
1411 .map(|(i, _)| i)
1412 .unwrap_or(0);
1413 line.replace_range(new_col..self.cursor_col, "");
1414 self.cursor_col = new_col;
1415 } else if self.cursor_row > 0 {
1416 let curr = self.input_lines.remove(self.cursor_row);
1418 self.cursor_row -= 1;
1419 let prev = &mut self.input_lines[self.cursor_row];
1420 self.cursor_col = prev.len();
1421 prev.push_str(&curr);
1422 }
1423 }
1424
1425 KeyCode::Enter if shift || key.modifiers.contains(KeyModifiers::ALT) => {
1427 let line = &mut self.input_lines[self.cursor_row];
1428 let rest = line.split_off(self.cursor_col);
1429 self.cursor_row += 1;
1430 self.cursor_col = 0;
1431 self.input_lines.insert(self.cursor_row, rest);
1432 }
1433
1434 KeyCode::Enter => {
1436 let task = self.current_input().trim().to_string();
1437 if !task.is_empty() {
1438 match task.as_str() {
1440 "/clear" => {
1441 self.lines.clear();
1442 self.groups.clear();
1443 self.current_group = None;
1444 self.focus_group = None;
1445 }
1446 "/collapse" => {
1447 for g in &mut self.groups {
1448 g.collapsed = true;
1449 }
1450 }
1451 "/expand" => {
1452 for g in &mut self.groups {
1453 g.collapsed = false;
1454 }
1455 }
1456 "/exit" | "/quit" => break,
1457 "/help" => {
1458 self.add_line("Commands:", LogStyle::Brand, 0);
1459 for c in SLASH_COMMANDS {
1460 self.add_line(c, LogStyle::Dim, 1);
1461 }
1462 self.add_line(
1463 "Ctrl+I inject · Ctrl+L clear · Ctrl+↑/↓ focus task · Ctrl+O fold/unfold · Shift+Enter newline · Up/Down history",
1464 LogStyle::Dim, 0,
1465 );
1466 self.add_line(
1467 "/collapse · /expand — fold/unfold all tasks",
1468 LogStyle::Dim,
1469 1,
1470 );
1471 }
1472 s if s.starts_with("/plan") => {
1473 let planned = s.trim_start_matches("/plan").trim();
1474 if planned.is_empty() {
1475 self.add_line("Usage: /plan <task>", LogStyle::Warn, 0);
1476 } else {
1477 let plan =
1478 crate::plan::build_read_only_plan(planned, &[]);
1479 self.add_line(
1480 "Read-only plan · no tools or edits executed",
1481 LogStyle::Planner,
1482 0,
1483 );
1484 self.add_line(&plan.summary, LogStyle::Dim, 1);
1485 for (idx, step) in plan.steps.iter().enumerate() {
1486 self.add_line(
1487 &format!("{}. {}", idx + 1, step),
1488 LogStyle::Cmd,
1489 1,
1490 );
1491 }
1492 self.add_line(
1493 "Run the task explicitly when you accept the plan.",
1494 LogStyle::Warn,
1495 0,
1496 );
1497 }
1498 }
1499 _ => {
1500 let label = if self.inject_pending {
1502 "inject"
1503 } else {
1504 "sparrow"
1505 };
1506 self.add_line(
1507 &format!("{} › {}", label, task.replace('\n', " ↵ ")),
1508 LogStyle::Prompt,
1509 0,
1510 );
1511 self.push_history(&task);
1512 let to_send = if self.inject_pending {
1513 format!("__inject__:{}", task)
1514 } else {
1515 let prefix = self.agent_prefix();
1516 if prefix.is_empty() {
1517 task.clone()
1518 } else {
1519 format!("{}{}", prefix, task)
1520 }
1521 };
1522 self.inject_pending = false;
1523 if let Some(tx) = &self.task_tx {
1524 if tx.send(to_send).is_err() {
1525 self.add_line(
1526 "runtime channel disconnected",
1527 LogStyle::Err,
1528 0,
1529 );
1530 }
1531 }
1532 }
1533 }
1534 self.set_input("");
1535 self.history_idx = None;
1536 }
1537 }
1538
1539 KeyCode::Char(c) => {
1541 let line = &mut self.input_lines[self.cursor_row];
1542 line.insert(self.cursor_col, c);
1543 self.cursor_col += c.len_utf8();
1544 }
1545
1546 KeyCode::Left => {
1548 if self.scroll == 0
1549 && self.cursor_col == 0
1550 && self.checkpoints.len() > 1
1551 {
1552 let previous = self
1553 .checkpoints
1554 .iter()
1555 .rev()
1556 .skip(1)
1557 .find(|node| !node.id.is_empty())
1558 .map(|node| node.id.clone());
1559 if let (Some(id), Some(tx)) = (previous, &self.task_tx) {
1560 let _ = tx.send(format!("__rewind__:{}", id));
1561 self.add_line(
1562 "rewind requested from checkpoint timeline",
1563 LogStyle::Gold,
1564 0,
1565 );
1566 }
1567 } else if self.cursor_col > 0 {
1568 self.cursor_col = self.input_lines[self.cursor_row]
1569 [..self.cursor_col]
1570 .char_indices()
1571 .last()
1572 .map(|(i, _)| i)
1573 .unwrap_or(0);
1574 } else if self.cursor_row > 0 {
1575 self.cursor_row -= 1;
1576 self.cursor_col = self.input_lines[self.cursor_row].len();
1577 }
1578 }
1579 KeyCode::Right => {
1580 let line = &self.input_lines[self.cursor_row];
1581 if self.cursor_col < line.len() {
1582 let next = line[self.cursor_col..]
1583 .chars()
1584 .next()
1585 .map(|c| c.len_utf8())
1586 .unwrap_or(0);
1587 self.cursor_col += next;
1588 } else if self.cursor_row + 1 < self.input_lines.len() {
1589 self.cursor_row += 1;
1590 self.cursor_col = 0;
1591 }
1592 }
1593
1594 _ => {}
1595 }
1596 }
1597 }
1598 }
1599 Ok(())
1600 }
1601
1602 fn tick_visuals(&mut self) {
1603 if !self.booted {
1604 self.boot_progress = self.boot_progress.saturating_add(1);
1605 if self.boot_progress >= 70 {
1606 self.boot();
1607 }
1608 }
1609 if self.cost_flash_frames > 0 {
1610 self.cost_flash_frames -= 1;
1611 }
1612 if self.tok_flash_frames > 0 {
1613 self.tok_flash_frames -= 1;
1614 }
1615 if let Some(toast) = self.toast.as_mut() {
1616 toast.age = toast.age.saturating_add(1);
1617 if toast.age >= toast.max_age {
1618 self.toast = None;
1619 }
1620 }
1621 for ember in &mut self.embers {
1622 ember.y -= ember.vy;
1623 ember.life = ember.life.saturating_add(1);
1624 if ember.life >= ember.max_life || ember.y < 0.0 {
1625 ember.y = 28.0 + (ember.x % 7) as f32;
1626 ember.life = 0;
1627 }
1628 }
1629 }
1630
1631 fn drain_engine_events(&mut self) {
1632 let mut disconnected = false;
1633 let mut events = Vec::new();
1634 if let Some(rx) = self.event_rx.as_mut() {
1635 loop {
1636 match rx.try_recv() {
1637 Ok(event) => events.push(event),
1638 Err(mpsc::error::TryRecvError::Empty) => break,
1639 Err(mpsc::error::TryRecvError::Disconnected) => {
1640 disconnected = true;
1641 break;
1642 }
1643 }
1644 }
1645 }
1646 for event in events {
1647 self.push_event(event);
1648 }
1649 if disconnected {
1650 self.event_rx = None;
1651 self.add_line("runtime event stream disconnected", LogStyle::Warn, 0);
1652 }
1653 }
1654
1655 fn render(&self, f: &mut Frame, _elapsed: f64) {
1656 let area = f.area();
1657 if !self.booted {
1658 self.render_boot(f, area);
1659 return;
1660 }
1661 let suggestions = self.autocomplete_matches();
1663 let input_height = (self.input_lines.len() as u16 + 2).max(3)
1664 + if !suggestions.is_empty() { 1 } else { 0 };
1665 let swarm_height = if self.swarm_lanes.is_some() { 5 } else { 0 };
1666 let diff_height = if self.pending_diffs.is_empty() { 0 } else { 12 };
1667 let checkpoint_height = if self.checkpoints.is_empty() { 0 } else { 2 };
1668 let chunks = Layout::default()
1669 .direction(Direction::Vertical)
1670 .constraints([
1671 Constraint::Length(3),
1672 Constraint::Length(swarm_height),
1673 Constraint::Min(0),
1674 Constraint::Length(diff_height),
1675 Constraint::Length(checkpoint_height),
1676 Constraint::Length(1), Constraint::Length(input_height),
1678 ])
1679 .split(area);
1680 self.render_cockpit(f, chunks[0]);
1681 if swarm_height > 0 {
1682 self.render_swarm_lanes(f, chunks[1]);
1683 }
1684 self.render_scroll(f, chunks[2]);
1685 if diff_height > 0 {
1686 self.render_diff(f, chunks[3]);
1687 }
1688 if checkpoint_height > 0 {
1689 self.render_checkpoint_timeline(f, chunks[4]);
1690 }
1691 self.render_keyboard_hints(f, chunks[5]);
1692 self.render_input(f, chunks[6]);
1693 self.render_toast(f, area);
1694 }
1695
1696 fn render_boot(&self, f: &mut Frame, area: Rect) {
1697 let mut lines = Vec::new();
1698 let bird_lines: Vec<&str> = theme::ASCII_SPARROW.lines().collect();
1699 let bird_count = ((self.boot_progress / 5) as usize).min(bird_lines.len());
1700 for line in bird_lines.iter().take(bird_count) {
1701 lines.push(Line::from(Span::styled(
1702 *line,
1703 Style::default().fg(self.theme.brand),
1704 )));
1705 }
1706 if self.boot_progress >= 25 {
1707 let wordmark = if self.boot_progress < 35 {
1708 "S P A R R O W"
1709 } else if self.boot_progress < 45 {
1710 "S P A R R O W"
1711 } else {
1712 "SPARROW"
1713 };
1714 lines.push(Line::from(Span::styled(
1715 wordmark,
1716 Style::default()
1717 .fg(self.theme.brand)
1718 .add_modifier(Modifier::BOLD),
1719 )));
1720 }
1721 #[cfg(target_os = "linux")]
1722 let sandbox_boot = "sandbox local-hardened · namespaces armed";
1723 #[cfg(not(target_os = "linux"))]
1724 let sandbox_boot = "sandbox path-boundary enforcement";
1725 let boot_log = [
1726 "router warming provider graph",
1727 "surfaces cli · webview · gateway",
1728 sandbox_boot,
1729 "skills library indexed",
1730 "memory sqlite profile loaded",
1731 "autonomy dial ready",
1732 ];
1733 if self.boot_progress >= 45 {
1734 let count = (((self.boot_progress - 45) / 4) as usize).min(boot_log.len());
1735 for item in boot_log.iter().take(count) {
1736 lines.push(Line::from(Span::styled(
1737 *item,
1738 Style::default().fg(self.theme.dim),
1739 )));
1740 }
1741 }
1742 if self.boot_progress >= 68 {
1743 lines.push(Line::from(Span::styled(
1744 "✓ ready",
1745 Style::default()
1746 .fg(self.theme.add)
1747 .add_modifier(Modifier::BOLD),
1748 )));
1749 }
1750 let height = lines.len() as u16;
1751 let width = area.width.min(72);
1752 let rect = Rect {
1753 x: area.x + area.width.saturating_sub(width) / 2,
1754 y: area.y + area.height.saturating_sub(height.max(1)) / 2,
1755 width,
1756 height: height.max(1),
1757 };
1758 f.render_widget(Paragraph::new(Text::from(lines)), rect);
1759 }
1760
1761 fn render_cockpit(&self, f: &mut Frame, area: Rect) {
1762 let aut_color = match self.autonomy.as_str() {
1763 "autonomous" => self.theme.autonomous,
1764 "trusted" => self.theme.trusted,
1765 _ => self.theme.supervised,
1766 };
1767
1768 let spinner = self.theme.spinner_frame(self.spinner_idx);
1770 let verb = self.theme.flight_verb(self.frame as usize / 25);
1771
1772 let led = if self.frame / 8 % 2 == 0 {
1774 "●"
1775 } else {
1776 "◉"
1777 };
1778
1779 let cost_str = if self.cost_usd > 0.0 {
1785 format!("${:.4} ▲ ", self.cost_usd)
1786 } else {
1787 format!("${:.4} ", self.cost_usd)
1788 };
1789 let tok_str = format!("{} tok ", self.total_tokens);
1790 let aut_upper = self.autonomy.to_uppercase();
1791 let right_w = (cost_str.chars().count()
1793 + tok_str.chars().count()
1794 + 2 + aut_upper.chars().count()
1796 + 1) as u16;
1797
1798 let right = Line::from(vec![
1799 Span::styled(
1800 cost_str,
1801 if self.cost_flash_frames > 0 {
1802 Style::default()
1803 .fg(self.theme.gold)
1804 .add_modifier(Modifier::BOLD)
1805 } else {
1806 Style::default().fg(self.theme.brand)
1807 },
1808 ),
1809 Span::styled(
1811 tok_str,
1812 if self.tok_flash_frames > 0 {
1813 Style::default()
1814 .fg(self.theme.gold)
1815 .add_modifier(Modifier::BOLD)
1816 } else {
1817 Style::default().fg(self.theme.steel)
1818 },
1819 ),
1820 Span::styled(
1822 format!("{} ", led),
1823 Style::default().fg(aut_color).add_modifier(Modifier::BOLD),
1824 ),
1825 Span::styled(
1826 aut_upper,
1827 Style::default().fg(aut_color).add_modifier(Modifier::BOLD),
1828 ),
1829 ]);
1830
1831 let block = Block::default()
1834 .borders(Borders::ALL)
1835 .border_style(Style::default().fg(self.theme.line));
1836 let inner = block.inner(area);
1837 f.render_widget(block, area);
1838 let zones = Layout::default()
1839 .direction(Direction::Horizontal)
1840 .constraints([Constraint::Min(0), Constraint::Length(right_w)])
1841 .split(inner);
1842
1843 let agent_badge = match &self.active_agent {
1848 Some(agent) => format!("🐦 {} ", agent.to_uppercase()),
1850 None => String::new(),
1851 };
1852 let agent_w = if agent_badge.is_empty() {
1853 0
1854 } else {
1855 agent_badge.chars().count() + 1 };
1857 let prefix_w = 2 + 9 + 11 + agent_w + 7;
1860 let route_budget = (zones[0].width as usize).saturating_sub(prefix_w);
1861 let route_disp = truncate_for_width(&self.route, route_budget);
1862 let left = Line::from(vec![
1863 Span::styled(
1864 format!("{} ", spinner),
1865 Style::default()
1866 .fg(self.theme.brand)
1867 .add_modifier(Modifier::BOLD),
1868 ),
1869 Span::styled(
1870 "SPARROW ",
1871 Style::default()
1872 .fg(self.theme.brand)
1873 .add_modifier(Modifier::BOLD),
1874 ),
1875 Span::styled(
1876 format!("{:<9} ", verb),
1877 Style::default().fg(self.theme.dim),
1878 ),
1879 Span::styled(
1880 agent_badge,
1881 Style::default()
1882 .fg(self.theme.gold)
1883 .add_modifier(Modifier::BOLD),
1884 ),
1885 Span::styled(
1886 format!("route: {}", route_disp),
1887 Style::default().fg(self.theme.planner),
1888 ),
1889 ]);
1890 f.render_widget(Paragraph::new(left), zones[0]);
1891 f.render_widget(
1892 Paragraph::new(right).alignment(ratatui::layout::Alignment::Right),
1893 zones[1],
1894 );
1895 }
1896
1897 fn render_swarm_lanes(&self, f: &mut Frame, area: Rect) {
1898 let Some(lanes) = &self.swarm_lanes else {
1899 return;
1900 };
1901 let cols = Layout::default()
1902 .direction(Direction::Horizontal)
1903 .constraints([
1904 Constraint::Percentage(33),
1905 Constraint::Percentage(34),
1906 Constraint::Percentage(33),
1907 ])
1908 .split(area);
1909 let age = self.frame.saturating_sub(lanes.started_at_frame);
1910 let items = [
1911 ("planner", &lanes.planner, self.theme.planner),
1912 ("coder", &lanes.coder, self.theme.agent),
1913 ("verifier", &lanes.verifier, self.theme.verifier),
1914 ];
1915 for (idx, (role, lane, color)) in items.iter().enumerate() {
1916 let working = lane.status == "Working" || lane.status == "Thinking";
1917 let icon = match lane.status.as_str() {
1918 "Done" => "✓",
1919 "Error" => "✗",
1920 "Idle" => "◌",
1921 _ if self.frame / 8 % 2 == 0 => "●",
1922 _ => "○",
1923 };
1924 let caret = if working && self.frame / 8 % 2 == 0 {
1925 " ▌"
1926 } else {
1927 ""
1928 };
1929 let note_width = cols[idx].width.saturating_sub(4) as usize;
1930 let note = truncate_for_width(&lane.note, note_width);
1931 let lines = vec![
1932 Line::from(Span::styled(
1933 format!("{} {}", role.to_uppercase(), lane.model),
1934 Style::default().fg(*color).add_modifier(Modifier::BOLD),
1935 )),
1936 Line::from(Span::styled(
1937 format!("{} {}{}", icon, lane.status, caret),
1938 Style::default().fg(if working { self.theme.gold } else { *color }),
1939 )),
1940 Line::from(Span::styled(note, Style::default().fg(self.theme.fg))),
1941 ];
1942 f.render_widget(
1943 Paragraph::new(Text::from(lines)).block(
1944 Block::default()
1945 .borders(Borders::ALL)
1946 .title(format!("swarm {}", age.min(99)))
1947 .border_style(Style::default().fg(*color)),
1948 ),
1949 cols[idx],
1950 );
1951 }
1952 }
1953
1954 fn render_scroll(&self, f: &mut Frame, area: Rect) {
1955 let max_lines = area.height.saturating_sub(2) as usize;
1956 if max_lines == 0 {
1957 return;
1958 }
1959 let rendered: Vec<Line> = self
1961 .lines
1962 .iter()
1963 .filter_map(|log| {
1964 if let Some(g) = log.group {
1966 if self.groups.get(g).map(|gr| gr.collapsed).unwrap_or(false) {
1967 return None;
1968 }
1969 }
1970 if let Some(gid) = log.header_for {
1971 let gr = self.groups.get(gid);
1973 let collapsed = gr.map(|g| g.collapsed).unwrap_or(false);
1974 let title = gr.map(|g| g.title.as_str()).unwrap_or(log.text.as_str());
1975 let log_style = gr.map(|g| g.style).unwrap_or(log.style);
1976 let arrow = if collapsed { "▸" } else { "▾" };
1977 let focused = self.focus_group == Some(gid);
1978 let n = self.group_child_count(gid);
1979 let hint = if collapsed && n > 0 {
1980 format!(" ({} hidden)", n)
1981 } else {
1982 String::new()
1983 };
1984 let marker = if focused { "‣ " } else { " " };
1985 let mut style = Style::default().fg(log_style.color(&self.theme));
1986 if focused {
1987 style = style.add_modifier(Modifier::BOLD | Modifier::UNDERLINED);
1988 }
1989 Some(Line::from(Span::styled(
1990 format!("{}{} {}{}", marker, arrow, title, hint),
1991 style,
1992 )))
1993 } else {
1994 let formatted = self.format_line(&log.text);
1995 let prefix = " ".repeat(log.indent as usize);
1996 let rendered_line = crate::tui::ansi_bridge::render_line(
1997 &formatted,
1998 Style::default().fg(log.style.color(&self.theme)),
1999 );
2000 let mut final_spans =
2002 vec![Span::styled(prefix, Style::default().fg(self.theme.dim))];
2003 final_spans.extend(rendered_line.spans);
2004 Some(Line::from(final_spans))
2005 }
2006 })
2007 .collect();
2008
2009 let total = rendered.len();
2010 let skip = (self.scroll as usize).min(total.saturating_sub(1));
2011 let show_logo = self.frame.saturating_sub(70) < 120 && self.scroll == 0;
2012 let logo_lines: Vec<Line> = if show_logo {
2013 theme::ascii_sparrow_at_frame(self.frame)
2014 .lines()
2015 .map(|line| {
2016 Line::from(Span::styled(
2017 line.to_string(),
2018 Style::default().fg(self.theme.brand),
2019 ))
2020 })
2021 .collect()
2022 } else {
2023 Vec::new()
2024 };
2025 let remaining = max_lines.saturating_sub(logo_lines.len());
2026 let mut text_lines: Vec<Line> = logo_lines;
2027 let start = total.saturating_sub(skip).saturating_sub(remaining);
2028 let end = total.saturating_sub(skip);
2029 text_lines.extend(rendered[start..end].iter().cloned());
2030 f.render_widget(
2031 Paragraph::new(Text::from(text_lines)).block(
2032 Block::default()
2033 .borders(Borders::ALL)
2034 .border_style(Style::default().fg(self.theme.line)),
2035 ),
2036 area,
2037 );
2038 self.render_embers(f, area);
2039 }
2040
2041 fn render_embers(&self, f: &mut Frame, area: Rect) {
2042 if area.width < 3 || area.height < 3 {
2043 return;
2044 }
2045 for ember in &self.embers {
2046 let x = area.x + 1 + (ember.x % area.width.saturating_sub(2));
2047 let y_offset = (ember.y.max(0.0) as u16) % area.height.saturating_sub(2);
2048 let y = area.y + 1 + y_offset;
2049 let color = if ember.amber {
2050 self.theme.gold
2051 } else {
2052 self.theme.rem
2053 };
2054 if let Some(cell) = f.buffer_mut().cell_mut((x, y)) {
2055 cell.set_char(ember.glyph).set_fg(color);
2056 }
2057 }
2058 }
2059
2060 fn render_diff(&self, f: &mut Frame, area: Rect) {
2061 let Some(diff) = self.pending_diffs.back() else {
2062 return;
2063 };
2064 let mut lines = vec![Line::from(vec![
2065 Span::styled("◇ ", Style::default().fg(self.theme.gold)),
2066 Span::styled(
2067 truncate_for_width(&diff.file, area.width.saturating_sub(20) as usize),
2068 Style::default()
2069 .fg(self.theme.brand)
2070 .add_modifier(Modifier::BOLD),
2071 ),
2072 Span::styled(
2073 format!(" +{} / -{} · proposed", diff.plus, diff.minus),
2074 Style::default().fg(self.theme.dim),
2075 ),
2076 ])];
2077 for (idx, line) in diff
2078 .lines
2079 .iter()
2080 .take(area.height.saturating_sub(3) as usize)
2081 .enumerate()
2082 {
2083 let color = match line.kind {
2084 DiffLineKind::Plus => self.theme.add,
2085 DiffLineKind::Minus => self.theme.rem,
2086 DiffLineKind::Hunk => self.theme.gold,
2087 DiffLineKind::Context => self.theme.dim,
2088 };
2089 let mut spans = vec![Span::styled(
2090 format!("{:>4} ", idx + 1),
2091 Style::default().fg(self.theme.dimmer),
2092 )];
2093 spans.extend(syntax_spans(&line.text, &self.theme, color));
2094 lines.push(Line::from(spans));
2095 }
2096 f.render_widget(
2097 Paragraph::new(Text::from(lines)).block(
2098 Block::default()
2099 .borders(Borders::ALL)
2100 .title("diff")
2101 .border_style(Style::default().fg(self.theme.line)),
2102 ),
2103 area,
2104 );
2105 }
2106
2107 fn render_checkpoint_timeline(&self, f: &mut Frame, area: Rect) {
2108 let mut spans = Vec::new();
2109 for (idx, node) in self
2110 .checkpoints
2111 .iter()
2112 .rev()
2113 .take(8)
2114 .collect::<Vec<_>>()
2115 .iter()
2116 .rev()
2117 .enumerate()
2118 {
2119 if idx > 0 {
2120 spans.push(Span::styled("──", Style::default().fg(self.theme.dimmer)));
2121 }
2122 spans.push(Span::styled(
2123 if node.current { "●" } else { "◆" },
2124 Style::default().fg(if node.current {
2125 self.theme.gold
2126 } else {
2127 self.theme.dim
2128 }),
2129 ));
2130 }
2131 if let Some(current) = self.checkpoints.iter().find(|n| n.current) {
2132 spans.push(Span::styled(
2133 format!(
2134 " {} · {}",
2135 truncate_for_width(¤t.label, 36),
2136 current.id.chars().take(8).collect::<String>()
2137 ),
2138 Style::default().fg(self.theme.dim),
2139 ));
2140 }
2141 spans.push(Span::styled(
2142 " rewind ← · snapshot before each batch",
2143 Style::default().fg(self.theme.dimmer),
2144 ));
2145 f.render_widget(Paragraph::new(Line::from(spans)), area);
2146 }
2147
2148 fn render_toast(&self, f: &mut Frame, area: Rect) {
2149 let Some(toast) = &self.toast else {
2150 return;
2151 };
2152 let width = (toast.text.chars().count() as u16 + 6).min(area.width.saturating_sub(2));
2153 if width < 8 || area.height < 5 {
2154 return;
2155 }
2156 let rect = Rect {
2157 x: area.x + area.width.saturating_sub(width) / 2,
2158 y: area.y + area.height.saturating_sub(3) / 2,
2159 width,
2160 height: 3,
2161 };
2162 let border = if toast.age / 20 % 2 == 0 {
2163 Style::default()
2164 .fg(self.theme.gold)
2165 .add_modifier(Modifier::BOLD)
2166 } else {
2167 Style::default().fg(self.theme.gold)
2168 };
2169 f.render_widget(
2170 Paragraph::new(Line::from(Span::styled(
2171 toast.text.as_str(),
2172 Style::default()
2173 .fg(self.theme.gold)
2174 .add_modifier(Modifier::BOLD),
2175 )))
2176 .block(Block::default().borders(Borders::ALL).border_style(border)),
2177 rect,
2178 );
2179 }
2180
2181 fn render_keyboard_hints(&self, f: &mut Frame, area: Rect) {
2182 let hints =
2183 format!("Esc:quit Tab:agents /:search @:skills Ctrl+R:run Ctrl+C:stop F1:help",);
2184 let line = Line::from(Span::styled(hints, Style::default().fg(self.theme.dimmer)));
2185 f.render_widget(
2186 Paragraph::new(line).alignment(ratatui::layout::Alignment::Center),
2187 area,
2188 );
2189 }
2190
2191 fn render_input(&self, f: &mut Frame, area: Rect) {
2192 let cursor_char = if self.frame / 8 % 2 == 0 { "▌" } else { " " };
2193 let prompt = if self.inject_pending {
2194 "◆ inject › "
2195 } else {
2196 "◆ sparrow › "
2197 };
2198 let prompt_color = if self.inject_pending {
2199 self.theme.coral
2200 } else {
2201 self.theme.brand
2202 };
2203
2204 let mut text_lines: Vec<Line> = Vec::new();
2205 for (row_idx, line) in self.input_lines.iter().enumerate() {
2206 let mut spans: Vec<Span> = Vec::new();
2207 if row_idx == 0 {
2208 spans.push(Span::styled(
2209 prompt,
2210 Style::default()
2211 .fg(prompt_color)
2212 .add_modifier(Modifier::BOLD),
2213 ));
2214 } else {
2215 spans.push(Span::styled(
2216 " › ",
2217 Style::default().fg(self.theme.dimmer),
2218 ));
2219 }
2220 if row_idx == self.cursor_row {
2221 let (before, after) = line.split_at(self.cursor_col.min(line.len()));
2222 spans.push(Span::styled(before, Style::default().fg(self.theme.fg)));
2223 spans.push(Span::styled(cursor_char, Style::default().fg(prompt_color)));
2224 spans.push(Span::styled(after, Style::default().fg(self.theme.fg)));
2225 } else {
2226 spans.push(Span::styled(
2227 line.as_str(),
2228 Style::default().fg(self.theme.fg),
2229 ));
2230 }
2231 text_lines.push(Line::from(spans));
2232 }
2233
2234 let suggestions = self.autocomplete_matches();
2236 if !suggestions.is_empty() {
2237 let mut s: Vec<Span> = vec![Span::styled(
2238 " ⇥ ",
2239 Style::default().fg(self.theme.dimmer),
2240 )];
2241 for (i, cmd) in suggestions.iter().enumerate() {
2242 if i == 0 {
2243 s.push(Span::styled(
2244 *cmd,
2245 Style::default()
2246 .fg(self.theme.brand)
2247 .add_modifier(Modifier::BOLD),
2248 ));
2249 } else {
2250 s.push(Span::styled(*cmd, Style::default().fg(self.theme.dim)));
2251 }
2252 s.push(Span::raw(" "));
2253 }
2254 text_lines.push(Line::from(s));
2255 }
2256
2257 f.render_widget(
2258 Paragraph::new(Text::from(text_lines)).block(
2259 Block::default()
2260 .borders(Borders::ALL)
2261 .border_style(Style::default().fg(self.theme.line)),
2262 ),
2263 area,
2264 );
2265 }
2266}
2267
2268impl Default for Tui {
2269 fn default() -> Self {
2270 Self::new()
2271 }
2272}
2273
2274#[cfg(test)]
2275mod v09_tests {
2276 use super::*;
2277 use crate::event::{Event, OutcomeSummary, RunId, TokenUsage};
2278
2279 fn run() -> RunId {
2280 RunId("t".into())
2281 }
2282
2283 #[test]
2284 fn simple_mode_renders_human_model_switch() {
2285 let mut tui = Tui::new().with_experience(true, crate::humanize::Lang::Fr);
2286 let before = tui.lines.len();
2287 tui.push_event(Event::ModelSwitched {
2288 run: run(),
2289 from: "a".into(),
2290 to: "b".into(),
2291 reason: "escalation".into(),
2292 });
2293 let added: Vec<String> = tui.lines[before..].iter().map(|l| l.text.clone()).collect();
2294 let joined = added.join("\n");
2295 assert!(
2296 joined.contains("vitesse supérieure") || joined.contains("Je change de modèle"),
2297 "simple mode should show a human switch line, got: {joined}"
2298 );
2299 assert!(!joined.contains("fallback:"), "jargon leaked: {joined}");
2301 }
2302
2303 #[test]
2304 fn pro_mode_keeps_technical_model_switch() {
2305 let mut tui = Tui::new().with_experience(false, crate::humanize::Lang::Fr);
2306 let before = tui.lines.len();
2307 tui.push_event(Event::ModelSwitched {
2308 run: run(),
2309 from: "a".into(),
2310 to: "b".into(),
2311 reason: "x".into(),
2312 });
2313 let joined: String = tui.lines[before..]
2314 .iter()
2315 .map(|l| l.text.clone())
2316 .collect::<Vec<_>>()
2317 .join("\n");
2318 assert!(joined.contains("a") && joined.contains("b"));
2319 }
2320
2321 #[test]
2322 fn builder_mode_renders_builder_menu() {
2323 let tui = Tui::new()
2324 .with_experience(false, crate::humanize::Lang::Fr)
2325 .with_experience_mode("builder");
2326 let text = tui
2327 .lines
2328 .iter()
2329 .map(|line| line.text.as_str())
2330 .collect::<Vec<_>>()
2331 .join("\n");
2332 assert!(
2333 text.contains("Builder menu"),
2334 "missing builder header:\n{text}"
2335 );
2336 for item in ["Run", "Test", "Refactor", "Git", "Debug", "Replay"] {
2337 assert!(text.contains(item), "missing builder item {item}:\n{text}");
2338 }
2339 }
2340
2341 #[test]
2342 fn simple_mode_run_finished_has_no_dollar_jargon() {
2343 let mut tui = Tui::new().with_experience(true, crate::humanize::Lang::Fr);
2344 let before = tui.lines.len();
2345 tui.push_event(Event::RunFinished {
2346 run: run(),
2347 outcome: OutcomeSummary {
2348 status: "completed".into(),
2349 diffs: vec![],
2350 cost_usd: 0.0,
2351 tokens: TokenUsage {
2352 input: 0,
2353 output: 0,
2354 },
2355 cost_comparison: String::new(),
2356 duration_ms: None,
2357 },
2358 });
2359 let joined: String = tui.lines[before..]
2360 .iter()
2361 .map(|l| l.text.clone())
2362 .collect::<Vec<_>>()
2363 .join("\n");
2364 assert!(
2365 joined.contains("Terminé") || joined.contains("gratuit"),
2366 "got: {joined}"
2367 );
2368 assert!(
2369 !joined.contains("status:"),
2370 "technical status leaked: {joined}"
2371 );
2372 }
2373}