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 lang: crate::humanize::Lang,
403}
404
405impl Tui {
406 pub fn new() -> Self {
407 let history_path = dirs::state_dir()
409 .or_else(dirs::data_local_dir)
410 .or_else(dirs::data_dir)
411 .map(|d| d.join("sparrow").join("tui_history.txt"));
412 let history = history_path
413 .as_ref()
414 .and_then(|p| std::fs::read_to_string(p).ok())
415 .map(|s| s.lines().map(String::from).collect())
416 .unwrap_or_default();
417
418 let theme = std::env::var("SPARROW_THEME")
420 .ok()
421 .map(|n| crate::tui::theme::by_name(&n))
422 .unwrap_or_default();
423 let mut tui = Self {
424 theme,
425 lines: Vec::new(),
426 route: "idle".into(),
427 cost_usd: 0.0,
428 total_tokens: 0,
429 autonomy: "supervised".into(),
430 input_lines: vec![String::new()],
431 cursor_row: 0,
432 cursor_col: 0,
433 history,
434 history_idx: None,
435 inject_pending: false,
436 scroll: 0,
437 frame: 0,
438 spinner_idx: 0,
439 booted: false,
440 boot_progress: 0,
441 event_rx: None,
442 task_tx: None,
443 history_path,
444 swarm_lanes: None,
445 pending_diffs: std::collections::VecDeque::new(),
446 checkpoints: Vec::new(),
447 embers: Self::spawn_embers(),
448 toast: None,
449 cost_flash_frames: 0,
450 last_cost: 0.0,
451 tok_flash_frames: 0,
452 last_tokens: 0,
453 groups: Vec::new(),
454 current_group: None,
455 focus_group: None,
456 replay_events: None,
457 replay_idx: 0,
458 think: crate::event::ThinkStripper::new(),
459 agent_names: Vec::new(),
460 active_agent: None,
461 agent_souls: std::collections::HashMap::new(),
462 term_renderer: crate::tui::renderer::TermRenderer::new(
463 crate::tui::renderer::RenderConfig::default(),
464 ),
465 simple: true,
467 lang: crate::humanize::Lang::Fr,
468 };
469 tui.show_splash();
470 tui
471 }
472
473 pub fn with_experience(mut self, simple: bool, lang: crate::humanize::Lang) -> Self {
475 self.simple = simple;
476 self.lang = lang;
477 self
478 }
479
480 fn show_splash(&mut self) {
482 self.add_line("══════════════════════════════════════", LogStyle::Brand, 0);
483 self.add_line(
484 " 🐦 SPARROW — one cli · grows with you",
485 LogStyle::Brand,
486 0,
487 );
488 self.add_line("══════════════════════════════════════", LogStyle::Brand, 0);
489 self.add_line("", LogStyle::Cmd, 0);
490 self.add_line("Try these (type in the input below):", LogStyle::Cmd, 0);
491 self.add_line(" @nova → Tab to toggle Nova agent", LogStyle::Dim, 0);
492 self.add_line(" /help → list all slash commands", LogStyle::Dim, 0);
493 self.add_line(" Ctrl+R → rewind to last checkpoint", LogStyle::Dim, 0);
494 self.add_line("", LogStyle::Cmd, 0);
495 self.add_line("# RICH RENDERING DEMO", LogStyle::Gold, 0);
497 self.add_line("", LogStyle::Cmd, 0);
498 self.add_line("Code blocks get syntax highlighting:", LogStyle::Cmd, 0);
499 self.add_line("```rust", LogStyle::Dim, 0);
500 self.add_line("fn main() {", LogStyle::Cmd, 0);
501 self.add_line(" println!(\"Hello, Sparrow!\");", LogStyle::Cmd, 0);
502 self.add_line("}", LogStyle::Cmd, 0);
503 self.add_line("```", LogStyle::Dim, 0);
504 self.add_line("", LogStyle::Cmd, 0);
505 self.add_line(
506 "Diffs are colored (additions in green, deletions in red):",
507 LogStyle::Cmd,
508 0,
509 );
510 self.add_line("--- a/src/main.rs", LogStyle::Dim, 0);
511 self.add_line("+++ b/src/main.rs", LogStyle::Dim, 0);
512 self.add_line("@@ -10,6 +10,8 @@ fn main() {", LogStyle::Dim, 0);
513 self.add_line("+ let config = load_config()?;", LogStyle::Ok, 0);
514 self.add_line(" let engine = Engine::new();", LogStyle::Cmd, 0);
515 self.add_line("- engine.run_old();", LogStyle::Err, 0);
516 self.add_line("+ engine.run_with_config(&config);", LogStyle::Ok, 0);
517 self.add_line("", LogStyle::Cmd, 0);
518 self.add_line("JSON is pretty-printed:", LogStyle::Cmd, 0);
519 self.add_line("{", LogStyle::Dim, 0);
520 self.add_line(" \"status\": \"ready\",", LogStyle::Ok, 0);
521 self.add_line(" \"version\": \"0.5.9\",", LogStyle::Gold, 0);
522 self.add_line(
523 " \"agents\": [\"nova\", \"planner\", \"coder\"]",
524 LogStyle::Cmd,
525 0,
526 );
527 self.add_line("}", LogStyle::Dim, 0);
528 self.add_line("", LogStyle::Cmd, 0);
529 self.add_line("→ Type a task or /command to begin.", LogStyle::Brand, 0);
530 }
531
532 pub fn with_replay(mut self, events: Vec<Event>) -> Self {
535 self.replay_events = Some(events);
536 self.replay_idx = 0;
537 self.booted = true; self
539 }
540
541 #[doc(hidden)]
544 pub fn force_booted(&mut self) {
545 self.booted = true;
546 }
547
548 #[doc(hidden)]
552 pub fn debug_set_boot_progress(&mut self, progress: u32) {
553 self.boot_progress = progress;
554 }
555
556 #[doc(hidden)]
563 pub fn render_to_lines(&mut self, width: u16, height: u16) -> Vec<String> {
564 let backend = ratatui::backend::TestBackend::new(width, height);
565 let mut terminal = ratatui::Terminal::new(backend).expect("test terminal");
566 terminal
567 .draw(|f| self.render(f, 0.0))
568 .expect("render must not fail");
569 let buf = terminal.backend().buffer().clone();
570 (0..height)
571 .map(|y| {
572 (0..width)
573 .map(|x| buf[(x, y)].symbol())
574 .collect::<String>()
575 .trim_end()
576 .to_string()
577 })
578 .collect()
579 }
580
581 fn rebuild_replay(&mut self) {
583 let Some(events) = self.replay_events.clone() else {
584 return;
585 };
586 self.lines.clear();
587 self.groups.clear();
588 self.current_group = None;
589 self.focus_group = None;
590 self.cost_usd = 0.0;
591 self.total_tokens = 0;
592 let upto = self.replay_idx.min(events.len());
593 for ev in events.iter().take(upto) {
594 self.push_event(ev.clone());
595 }
596 let total = events.len();
597 self.add_line(
598 &format!(
599 "── replay {}/{} (←/→ step · Home/End jump · q quit) ──",
600 upto, total
601 ),
602 LogStyle::Accent,
603 0,
604 );
605 }
606
607 fn spawn_embers() -> Vec<Ember> {
608 let glyphs = ['·', '•', '∘', '◦'];
610 (0..10u16)
611 .map(|i| Ember {
612 x: 4 + (i * 13) % 90,
613 y: 4.0 + ((i as f32) * 2.7) % 20.0,
614 vy: 0.10 + ((i as f32) * 0.037) % 0.25,
615 amber: i % 2 == 0,
616 life: ((i as u32) * 17) % 180,
617 max_life: 180 + ((i as u32) * 11) % 90,
618 glyph: glyphs[(i as usize) % glyphs.len()],
619 })
620 .collect()
621 }
622
623 fn current_input(&self) -> String {
625 self.input_lines.join("\n")
626 }
627
628 fn set_input(&mut self, s: &str) {
630 self.input_lines = s.split('\n').map(String::from).collect();
631 if self.input_lines.is_empty() {
632 self.input_lines.push(String::new());
633 }
634 self.cursor_row = self.input_lines.len() - 1;
635 self.cursor_col = self.input_lines[self.cursor_row].len();
636 }
637
638 fn push_history(&mut self, entry: &str) {
640 if entry.trim().is_empty() {
641 return;
642 }
643 if self.history.last().map(|s| s.as_str()) == Some(entry) {
644 return;
645 }
646 self.history.push(entry.to_string());
647 if self.history.len() > HISTORY_MAX {
648 let excess = self.history.len() - HISTORY_MAX;
649 self.history.drain(..excess);
650 }
651 if let Some(path) = &self.history_path {
652 if let Some(parent) = path.parent() {
653 let _ = std::fs::create_dir_all(parent);
654 }
655 let _ = std::fs::write(path, self.history.join("\n"));
656 }
657 }
658
659 fn autocomplete_matches(&self) -> Vec<&'static str> {
661 let line = &self.input_lines[0];
662 if line.starts_with('/') {
663 return SLASH_COMMANDS
664 .iter()
665 .filter(|c| c.starts_with(line.as_str()) && **c != line.as_str())
666 .copied()
667 .take(5)
668 .collect();
669 }
670 vec![]
671 }
672
673 #[doc(hidden)]
675 pub fn debug_first_line_mut(&mut self) -> &mut String {
676 if self.input_lines.is_empty() {
677 self.input_lines.push(String::new());
678 }
679 &mut self.input_lines[0]
680 }
681
682 #[doc(hidden)]
684 pub fn debug_set_cursor_col(&mut self, col: usize) {
685 self.cursor_row = 0;
686 self.cursor_col = col;
687 }
688
689 pub fn agent_matches(&self) -> Vec<String> {
692 let line = &self.input_lines[self.cursor_row];
694 let upto = line.get(..self.cursor_col).unwrap_or(line);
695 let Some(at_pos) = upto.rfind('@') else {
696 return vec![];
697 };
698 if at_pos > 0
701 && !upto[..at_pos]
702 .chars()
703 .last()
704 .map(|c| c.is_whitespace())
705 .unwrap_or(true)
706 {
707 return vec![];
708 }
709 let prefix = &upto[at_pos + 1..];
710 if prefix.contains(char::is_whitespace) {
712 return vec![];
713 }
714 self.agent_names
715 .iter()
716 .filter(|n| n.starts_with(prefix))
717 .take(5)
718 .map(|n| format!("@{}", n))
719 .collect()
720 }
721
722 pub fn with_agents(mut self, names: Vec<String>) -> Self {
724 self.agent_names = names;
725 self
726 }
727
728 pub fn toggle_agent(&mut self, name: &str) {
731 if self.active_agent.as_deref() == Some(name) {
732 self.active_agent = None;
734 } else {
735 self.active_agent = Some(name.to_string());
737 if !self.agent_souls.contains_key(name) {
738 self.cache_agent_soul(name);
739 }
740 }
741 }
742
743 fn cache_agent_soul(&mut self, name: &str) {
745 let path = dirs::config_dir()
746 .unwrap_or_default()
747 .join("sparrow")
748 .join("agents")
749 .join(format!("{}.soul.toml", name));
750 if let Ok(content) = std::fs::read_to_string(&path) {
751 let role = content
752 .lines()
753 .find(|l| l.starts_with("role"))
754 .and_then(|l| l.split('=').nth(1))
755 .map(|s| s.trim().trim_matches('"').to_string())
756 .unwrap_or_default();
757 let personality = content
758 .lines()
759 .find(|l| l.starts_with("personality"))
760 .and_then(|l| l.split('=').nth(1))
761 .map(|s| s.trim().trim_matches('"').to_string())
762 .unwrap_or_default();
763 use base64::{Engine as _, engine::general_purpose::STANDARD};
764 let b64 = STANDARD.encode(personality.as_bytes());
765 self.agent_souls.insert(name.to_string(), (role, b64));
766 }
767 }
768
769 fn agent_prefix(&self) -> String {
771 if let Some(ref name) = self.active_agent {
772 if let Some((role, b64)) = self.agent_souls.get(name) {
773 return format!("__agent:{}__{}__{}__ ", name, role, b64);
774 }
775 }
776 String::new()
777 }
778
779 pub fn with_channels(
780 mut self,
781 task_tx: mpsc::UnboundedSender<String>,
782 event_rx: mpsc::UnboundedReceiver<Event>,
783 ) -> Self {
784 self.task_tx = Some(task_tx);
785 self.event_rx = Some(event_rx);
786 self
787 }
788
789 fn format_line(&self, text: &str) -> String {
792 let trimmed = text.trim();
794
795 if trimmed.starts_with("```") || text.lines().all(|l| l.starts_with(" ") || l.is_empty())
797 {
798 return self.term_renderer.render_code(text, "");
799 }
800
801 if trimmed.contains("diff --git")
803 || trimmed.starts_with("@@")
804 || trimmed.starts_with("--- a/")
805 || trimmed.starts_with("+++ b/")
806 {
807 return self.term_renderer.render_diff(text);
808 }
809
810 if trimmed.starts_with('{') || trimmed.starts_with('[') {
812 if serde_json::from_str::<serde_json::Value>(trimmed).is_ok() {
813 return self.term_renderer.render_json(text);
814 }
815 }
816
817 if trimmed.starts_with("# ") || trimmed.starts_with("## ") || trimmed.starts_with("### ") {
819 return self.term_renderer.render_markdown(text);
820 }
821
822 text.to_string()
824 }
825
826 pub fn push_event(&mut self, event: Event) {
827 match &event {
828 Event::RunStarted { task, .. } => {
829 self.think = crate::event::ThinkStripper::new();
830 self.open_group(&format!("started: {}", task), LogStyle::Brand);
831 }
832 Event::RouteSelected { chain, .. } => {
833 self.route = chain.join(" → ");
834 self.add_line(&format!("↳ route: {}", self.route), LogStyle::Dim, 1);
835 }
836 Event::ModelSwitched {
837 from, to, reason, ..
838 } => {
839 self.route = to.clone();
840 if self.simple {
843 if let Some(line) = crate::humanize::humanize(&event, self.lang) {
844 self.add_line(&line, LogStyle::Warn, 1);
845 }
846 } else {
847 let clean = crate::event::friendly_model_switch_reason(reason);
848 let label = if crate::event::is_local_model_unavailable(reason) {
849 format!(
850 "↳ modèle local indisponible → routage modèle cloud ({})",
851 to
852 )
853 } else {
854 format!("↳ fallback: {} → {} ({})", from, to, clean)
855 };
856 self.add_line(&label, LogStyle::Warn, 1);
857 }
858 }
859 Event::ThinkingDelta { text, .. } => {
860 let visible = self.think.feed(text);
861 if !visible.is_empty() {
862 self.add_line(&visible, LogStyle::Cmd, 1);
863 }
864 }
865 Event::ReasoningDelta { .. } => {}
866 Event::ToolUseProposed { name, .. } => {
867 self.open_group(&format!("tool · {}", name), LogStyle::Steel);
868 }
869 Event::ToolOutput { blocks, .. } => {
870 for b in blocks {
871 if let crate::event::Block::Text(t) = b {
872 self.add_line(&format!(" {}", t), LogStyle::Dim, 2);
873 }
874 }
875 }
876 Event::AgentSpawned { role, model, .. } => {
877 let lanes = self.swarm_lanes.get_or_insert_with(|| SwarmLanesState {
878 started_at_frame: self.frame,
879 ..Default::default()
880 });
881 let lane = match role.as_str() {
882 "planner" => &mut lanes.planner,
883 "coder" => &mut lanes.coder,
884 "verifier" => &mut lanes.verifier,
885 _ => &mut lanes.coder,
886 };
887 lane.status = "Working".into();
888 lane.note = "spawned".into();
889 lane.model = model.clone();
890 let s = match role.as_str() {
891 "planner" => LogStyle::Planner,
892 "coder" => LogStyle::Agent,
893 "verifier" => LogStyle::Verifier,
894 _ => LogStyle::Dim,
895 };
896 self.open_group(&format!("{} ({})", role, model), s);
897 }
898 Event::AgentStatus {
899 role, note, status, ..
900 } => {
901 if let Some(lanes) = self.swarm_lanes.as_mut() {
902 let lane = match role.as_str() {
903 "planner" => &mut lanes.planner,
904 "coder" => &mut lanes.coder,
905 "verifier" => &mut lanes.verifier,
906 _ => &mut lanes.coder,
907 };
908 lane.status = format!("{:?}", status);
909 lane.note = note.clone();
910 }
911 let s = match role.as_str() {
912 "planner" => LogStyle::Planner,
913 "coder" => LogStyle::Agent,
914 "verifier" => LogStyle::Verifier,
915 _ => LogStyle::Dim,
916 };
917 let icon = match status {
918 crate::event::AgentStatus::Done => "✓",
919 crate::event::AgentStatus::Working => "●",
920 crate::event::AgentStatus::Thinking => "○",
921 crate::event::AgentStatus::Error => "✗",
922 _ => "◌",
923 };
924 self.add_line(&format!("{} {} — {}", icon, role, note), s, 1);
925 }
926 Event::CheckpointCreated { id, label, .. } => {
927 for node in &mut self.checkpoints {
928 node.current = false;
929 }
930 self.checkpoints.push(CheckpointNode {
931 id: id.0.clone(),
932 label: label.clone(),
933 current: true,
934 });
935 self.add_line(&format!("● checkpoint: {}", label), LogStyle::Gold, 0)
936 }
937 Event::SkillLearned { name, .. } => {
938 self.toast = Some(Toast {
939 text: format!("✦ skill learned · {}", name),
940 age: 0,
941 max_age: 90,
942 });
943 self.add_line(&format!("✦ skill learned · {}", name), LogStyle::Agent, 0)
944 }
945 Event::CostUpdate { usd, .. } => {
946 if *usd > self.last_cost {
947 self.cost_flash_frames = 12;
948 }
949 self.last_cost = *usd;
950 self.cost_usd = *usd;
951 }
952 Event::TokenUsage { input, output, .. } => {
953 self.total_tokens += input + output;
954 if self.total_tokens > self.last_tokens {
955 self.tok_flash_frames = 12;
956 }
957 self.last_tokens = self.total_tokens;
958 }
959 Event::TokenUsageEstimated { input, output, .. } => {
960 self.total_tokens += input + output;
961 if self.total_tokens > self.last_tokens {
962 self.tok_flash_frames = 12;
963 }
964 self.last_tokens = self.total_tokens;
965 }
966 Event::AutonomyChanged { level, .. } => {
967 self.autonomy = format!("{:?}", level).to_lowercase()
968 }
969 Event::DiffProposed {
970 file,
971 patch,
972 plus,
973 minus,
974 ..
975 } => {
976 if self.pending_diffs.len() >= 3 {
977 self.pending_diffs.pop_front();
978 }
979 self.pending_diffs.push_back(DiffEntry {
980 file: file.clone(),
981 plus: *plus,
982 minus: *minus,
983 lines: parse_diff_patch(patch),
984 applied: false,
985 });
986 self.add_line(
987 &format!("◇ {} +{} / -{} · proposed", file, plus, minus),
988 LogStyle::Dim,
989 0,
990 )
991 }
992 Event::DiffApplied { file, .. } => {
993 if let Some(entry) = self.pending_diffs.iter_mut().find(|d| d.file == *file) {
994 entry.applied = true;
995 }
996 while self.pending_diffs.front().is_some_and(|d| d.applied) {
997 self.pending_diffs.pop_front();
998 }
999 }
1000 Event::TestResult {
1001 passed,
1002 failed,
1003 detail,
1004 ..
1005 } => {
1006 if *failed > 0 {
1007 self.add_line(
1008 &format!("⚠ tests {} passed · {} failed", passed, failed),
1009 LogStyle::Warn,
1010 1,
1011 );
1012 for line in detail.lines() {
1013 self.add_line(&format!(" {}", line), LogStyle::Rem, 2);
1014 }
1015 } else {
1016 self.add_line(
1017 &format!("✓ tests {} passed · no regressions", passed),
1018 LogStyle::Ok,
1019 1,
1020 );
1021 }
1022 }
1023 Event::RunFinished { outcome, .. } => {
1024 let tail = self.think.flush();
1026 if !tail.trim().is_empty() {
1027 self.add_line(&tail, LogStyle::Cmd, 1);
1028 }
1029 self.close_group();
1030 if self.simple {
1031 if let Some(line) = crate::humanize::humanize(&event, self.lang) {
1034 self.add_line(&line, LogStyle::Ok, 0);
1035 }
1036 let usd = outcome.cost_usd;
1037 let cost_line = if usd <= 0.0 {
1038 "C'était gratuit.".to_string()
1039 } else if usd < 0.01 {
1040 "Coût : moins d'un centime.".to_string()
1041 } else {
1042 format!("Coût : environ {:.0} centimes.", usd * 100.0)
1043 };
1044 self.add_line(&cost_line, LogStyle::Dim, 1);
1045 } else {
1046 self.add_line(
1047 &format!(
1048 "✓ done status: {} cost: ${:.4}",
1049 outcome.status, outcome.cost_usd
1050 ),
1051 LogStyle::Ok,
1052 0,
1053 );
1054 if outcome.tokens.input > 0 || outcome.tokens.output > 0 {
1056 let comparison =
1057 crate::cost::format_comparison(outcome.cost_usd, &outcome.tokens);
1058 for line in comparison.lines().skip(1) {
1059 if !line.is_empty() && !line.starts_with("──") {
1061 let style = if line.contains("Sparrow") {
1062 LogStyle::Ok
1063 } else if line.contains("💡") {
1064 LogStyle::Warn
1065 } else {
1066 LogStyle::Rem
1067 };
1068 self.add_line(line, style, 1);
1069 }
1070 }
1071 }
1072 }
1073 }
1074 Event::Error { message, .. } => {
1075 if !crate::event::is_local_model_unavailable(message) {
1076 self.add_line(message, LogStyle::Err, 0);
1077 }
1078 }
1079 Event::UpdateAvailable {
1080 current,
1081 latest,
1082 install_cmd,
1083 ..
1084 } => {
1085 self.add_line(
1086 &format!(
1087 "📦 Sparrow v{} available (current: v{}). Run: {}",
1088 latest, current, install_cmd
1089 ),
1090 LogStyle::Warn,
1091 0,
1092 );
1093 }
1094 _ => {}
1095 }
1096 }
1097
1098 fn add_line(&mut self, text: &str, style: LogStyle, indent: u16) {
1099 let group = self.current_group;
1100 for line in text.lines() {
1101 self.lines.push(LogLine {
1102 text: line.to_string(),
1103 style,
1104 indent,
1105 group,
1106 header_for: None,
1107 });
1108 }
1109 }
1110
1111 fn open_group(&mut self, title: &str, style: LogStyle) {
1113 let id = self.groups.len();
1114 self.groups.push(TaskGroup {
1115 title: title.to_string(),
1116 collapsed: false,
1117 style,
1118 });
1119 self.lines.push(LogLine {
1120 text: title.to_string(),
1121 style,
1122 indent: 0,
1123 group: None,
1124 header_for: Some(id),
1125 });
1126 self.current_group = Some(id);
1127 self.focus_group = Some(id);
1128 }
1129
1130 fn close_group(&mut self) {
1132 self.current_group = None;
1133 }
1134
1135 fn group_child_count(&self, id: usize) -> usize {
1137 self.lines.iter().filter(|l| l.group == Some(id)).count()
1138 }
1139
1140 fn focus_group_step(&mut self, forward: bool) {
1142 if self.groups.is_empty() {
1143 return;
1144 }
1145 let last = self.groups.len() - 1;
1146 self.focus_group = Some(match self.focus_group {
1147 None => last,
1148 Some(i) if forward => (i + 1).min(last),
1149 Some(i) => i.saturating_sub(1),
1150 });
1151 }
1152
1153 fn toggle_group(&mut self) {
1155 match self.focus_group {
1156 Some(i) if i < self.groups.len() => {
1157 self.groups[i].collapsed = !self.groups[i].collapsed;
1158 }
1159 _ => {
1160 let any_open = self.groups.iter().any(|g| !g.collapsed);
1161 for g in &mut self.groups {
1162 g.collapsed = any_open;
1163 }
1164 }
1165 }
1166 }
1167
1168 fn boot(&mut self) {
1169 self.add_line(
1170 concat!(
1171 "SPARROW v",
1172 env!("CARGO_PKG_VERSION"),
1173 " — one cli · grows with you"
1174 ),
1175 LogStyle::Dim,
1176 0,
1177 );
1178 self.add_line("", LogStyle::Normal, 0);
1179
1180 #[cfg(target_os = "linux")]
1183 let sandbox_line = "local-hardened · namespaces + path boundary";
1184 #[cfg(not(target_os = "linux"))]
1185 let sandbox_line = "path-boundary enforcement (namespaces are Linux-only)";
1186
1187 let boot = [
1188 (
1189 "router ",
1190 "model routing + fallback chain",
1191 LogStyle::Planner,
1192 ),
1193 (
1194 "surfaces",
1195 "cli · tui · webview · gateway",
1196 LogStyle::Planner,
1197 ),
1198 ("sandbox ", sandbox_line, LogStyle::Ok),
1199 (
1200 "skills ",
1201 "library indexed · self-improving",
1202 LogStyle::Accent,
1203 ),
1204 (
1205 "memory ",
1206 "sqlite · bounded docs · session search",
1207 LogStyle::Ok,
1208 ),
1209 (
1210 "autonomy",
1211 "dial: supervised → trusted → autonomous",
1212 LogStyle::Accent,
1213 ),
1214 ];
1215 for (k, v, s) in &boot {
1216 self.add_line(&format!("{} {}", k, v), *s, 1);
1217 }
1218 self.add_line("✓ ready one binary. no dependencies.", LogStyle::Ok, 0);
1219 self.add_line("", LogStyle::Normal, 0);
1220 self.booted = true;
1221 }
1222
1223 pub fn run(&mut self) -> io::Result<()> {
1224 ensure_utf8_console();
1230 enable_raw_mode()?;
1231 let mut stdout = io::stdout();
1232 execute!(stdout, EnterAlternateScreen)?;
1233 let backend = ratatui::backend::CrosstermBackend::new(stdout);
1234 let mut terminal = ratatui::Terminal::new(backend)?;
1235 terminal.clear()?;
1239 let result = self.main_loop(&mut terminal);
1240 disable_raw_mode()?;
1241 execute!(io::stdout(), LeaveAlternateScreen)?;
1242 result
1243 }
1244
1245 fn main_loop(&mut self, terminal: &mut CrosstermTerminal) -> io::Result<()> {
1246 let start = Instant::now();
1247 if self.replay_events.is_some() {
1248 self.rebuild_replay();
1249 }
1250 loop {
1251 self.drain_engine_events();
1252 self.frame += 1;
1253 self.spinner_idx = (self.spinner_idx + 1) % 10;
1254 self.tick_visuals();
1255 terminal.draw(|f| self.render(f, start.elapsed().as_secs_f64()))?;
1256 if event::poll(std::time::Duration::from_millis(50))? {
1257 if let TermEvent::Key(key) = event::read()? {
1258 if key.kind != KeyEventKind::Press {
1259 continue;
1260 }
1261 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1262 let shift = key.modifiers.contains(KeyModifiers::SHIFT);
1263 match key.code {
1264 KeyCode::Esc => break,
1265 KeyCode::Char('c') if ctrl => break,
1266
1267 KeyCode::Char('q') if self.replay_events.is_some() => break,
1269 KeyCode::Left if self.replay_events.is_some() => {
1270 self.replay_idx = self.replay_idx.saturating_sub(1);
1271 self.rebuild_replay();
1272 }
1273 KeyCode::Right if self.replay_events.is_some() => {
1274 let max = self.replay_events.as_ref().map(|e| e.len()).unwrap_or(0);
1275 self.replay_idx = (self.replay_idx + 1).min(max);
1276 self.rebuild_replay();
1277 }
1278 KeyCode::Home if self.replay_events.is_some() => {
1279 self.replay_idx = 0;
1280 self.rebuild_replay();
1281 }
1282 KeyCode::End if self.replay_events.is_some() => {
1283 self.replay_idx =
1284 self.replay_events.as_ref().map(|e| e.len()).unwrap_or(0);
1285 self.rebuild_replay();
1286 }
1287
1288 KeyCode::Char('l') if ctrl => {
1290 self.lines.clear();
1291 }
1292 KeyCode::Char('i') if ctrl => {
1294 self.inject_pending = true;
1295 self.add_line(
1296 "[inject] next message will be sent to the running agent",
1297 LogStyle::Warn,
1298 0,
1299 );
1300 }
1301
1302 KeyCode::Up if ctrl => self.focus_group_step(false),
1305 KeyCode::Down if ctrl => self.focus_group_step(true),
1306 KeyCode::Char('o') if ctrl => self.toggle_group(),
1307
1308 KeyCode::Up if self.cursor_row == 0 && !self.history.is_empty() => {
1310 let new_idx = match self.history_idx {
1311 None => self.history.len() - 1,
1312 Some(0) => 0,
1313 Some(i) => i - 1,
1314 };
1315 self.history_idx = Some(new_idx);
1316 let entry = self.history[new_idx].clone();
1317 self.set_input(&entry);
1318 }
1319 KeyCode::Down if self.cursor_row == self.input_lines.len() - 1 => {
1320 match self.history_idx {
1321 Some(i) if i + 1 < self.history.len() => {
1322 self.history_idx = Some(i + 1);
1323 let entry = self.history[i + 1].clone();
1324 self.set_input(&entry);
1325 }
1326 Some(_) => {
1327 self.history_idx = None;
1328 self.set_input("");
1329 }
1330 None => {}
1331 }
1332 }
1333
1334 KeyCode::PageUp => self.scroll = self.scroll.saturating_add(10),
1336 KeyCode::PageDown => self.scroll = self.scroll.saturating_sub(10),
1337 KeyCode::Home => self.scroll = 0,
1338 KeyCode::End => self.scroll = u16::MAX,
1339
1340 KeyCode::Tab => {
1342 let line = &self.input_lines[0];
1343 if let Some(rest) = line.strip_prefix('@') {
1345 let name = &rest.trim().to_string();
1346 if !name.is_empty() && self.agent_names.contains(name) {
1347 self.toggle_agent(name);
1348 self.input_lines = vec![String::new()];
1349 self.cursor_row = 0;
1350 self.cursor_col = 0;
1351 }
1352 } else {
1353 let matches = self.autocomplete_matches();
1354 if let Some(first) = matches.first() {
1355 self.input_lines = vec![first.to_string()];
1356 self.cursor_row = 0;
1357 self.cursor_col = first.len();
1358 }
1359 }
1360 }
1361
1362 KeyCode::Backspace => {
1364 if self.cursor_col > 0 {
1365 let line = &mut self.input_lines[self.cursor_row];
1366 let new_col = line[..self.cursor_col]
1367 .char_indices()
1368 .last()
1369 .map(|(i, _)| i)
1370 .unwrap_or(0);
1371 line.replace_range(new_col..self.cursor_col, "");
1372 self.cursor_col = new_col;
1373 } else if self.cursor_row > 0 {
1374 let curr = self.input_lines.remove(self.cursor_row);
1376 self.cursor_row -= 1;
1377 let prev = &mut self.input_lines[self.cursor_row];
1378 self.cursor_col = prev.len();
1379 prev.push_str(&curr);
1380 }
1381 }
1382
1383 KeyCode::Enter if shift || key.modifiers.contains(KeyModifiers::ALT) => {
1385 let line = &mut self.input_lines[self.cursor_row];
1386 let rest = line.split_off(self.cursor_col);
1387 self.cursor_row += 1;
1388 self.cursor_col = 0;
1389 self.input_lines.insert(self.cursor_row, rest);
1390 }
1391
1392 KeyCode::Enter => {
1394 let task = self.current_input().trim().to_string();
1395 if !task.is_empty() {
1396 match task.as_str() {
1398 "/clear" => {
1399 self.lines.clear();
1400 self.groups.clear();
1401 self.current_group = None;
1402 self.focus_group = None;
1403 }
1404 "/collapse" => {
1405 for g in &mut self.groups {
1406 g.collapsed = true;
1407 }
1408 }
1409 "/expand" => {
1410 for g in &mut self.groups {
1411 g.collapsed = false;
1412 }
1413 }
1414 "/exit" | "/quit" => break,
1415 "/help" => {
1416 self.add_line("Commands:", LogStyle::Brand, 0);
1417 for c in SLASH_COMMANDS {
1418 self.add_line(c, LogStyle::Dim, 1);
1419 }
1420 self.add_line(
1421 "Ctrl+I inject · Ctrl+L clear · Ctrl+↑/↓ focus task · Ctrl+O fold/unfold · Shift+Enter newline · Up/Down history",
1422 LogStyle::Dim, 0,
1423 );
1424 self.add_line(
1425 "/collapse · /expand — fold/unfold all tasks",
1426 LogStyle::Dim,
1427 1,
1428 );
1429 }
1430 s if s.starts_with("/plan") => {
1431 let planned = s.trim_start_matches("/plan").trim();
1432 if planned.is_empty() {
1433 self.add_line("Usage: /plan <task>", LogStyle::Warn, 0);
1434 } else {
1435 let plan =
1436 crate::plan::build_read_only_plan(planned, &[]);
1437 self.add_line(
1438 "Read-only plan · no tools or edits executed",
1439 LogStyle::Planner,
1440 0,
1441 );
1442 self.add_line(&plan.summary, LogStyle::Dim, 1);
1443 for (idx, step) in plan.steps.iter().enumerate() {
1444 self.add_line(
1445 &format!("{}. {}", idx + 1, step),
1446 LogStyle::Cmd,
1447 1,
1448 );
1449 }
1450 self.add_line(
1451 "Run the task explicitly when you accept the plan.",
1452 LogStyle::Warn,
1453 0,
1454 );
1455 }
1456 }
1457 _ => {
1458 let label = if self.inject_pending {
1460 "inject"
1461 } else {
1462 "sparrow"
1463 };
1464 self.add_line(
1465 &format!("{} › {}", label, task.replace('\n', " ↵ ")),
1466 LogStyle::Prompt,
1467 0,
1468 );
1469 self.push_history(&task);
1470 let to_send = if self.inject_pending {
1471 format!("__inject__:{}", task)
1472 } else {
1473 let prefix = self.agent_prefix();
1474 if prefix.is_empty() {
1475 task.clone()
1476 } else {
1477 format!("{}{}", prefix, task)
1478 }
1479 };
1480 self.inject_pending = false;
1481 if let Some(tx) = &self.task_tx {
1482 if tx.send(to_send).is_err() {
1483 self.add_line(
1484 "runtime channel disconnected",
1485 LogStyle::Err,
1486 0,
1487 );
1488 }
1489 }
1490 }
1491 }
1492 self.set_input("");
1493 self.history_idx = None;
1494 }
1495 }
1496
1497 KeyCode::Char(c) => {
1499 let line = &mut self.input_lines[self.cursor_row];
1500 line.insert(self.cursor_col, c);
1501 self.cursor_col += c.len_utf8();
1502 }
1503
1504 KeyCode::Left => {
1506 if self.scroll == 0
1507 && self.cursor_col == 0
1508 && self.checkpoints.len() > 1
1509 {
1510 let previous = self
1511 .checkpoints
1512 .iter()
1513 .rev()
1514 .skip(1)
1515 .find(|node| !node.id.is_empty())
1516 .map(|node| node.id.clone());
1517 if let (Some(id), Some(tx)) = (previous, &self.task_tx) {
1518 let _ = tx.send(format!("__rewind__:{}", id));
1519 self.add_line(
1520 "rewind requested from checkpoint timeline",
1521 LogStyle::Gold,
1522 0,
1523 );
1524 }
1525 } else if self.cursor_col > 0 {
1526 self.cursor_col = self.input_lines[self.cursor_row]
1527 [..self.cursor_col]
1528 .char_indices()
1529 .last()
1530 .map(|(i, _)| i)
1531 .unwrap_or(0);
1532 } else if self.cursor_row > 0 {
1533 self.cursor_row -= 1;
1534 self.cursor_col = self.input_lines[self.cursor_row].len();
1535 }
1536 }
1537 KeyCode::Right => {
1538 let line = &self.input_lines[self.cursor_row];
1539 if self.cursor_col < line.len() {
1540 let next = line[self.cursor_col..]
1541 .chars()
1542 .next()
1543 .map(|c| c.len_utf8())
1544 .unwrap_or(0);
1545 self.cursor_col += next;
1546 } else if self.cursor_row + 1 < self.input_lines.len() {
1547 self.cursor_row += 1;
1548 self.cursor_col = 0;
1549 }
1550 }
1551
1552 _ => {}
1553 }
1554 }
1555 }
1556 }
1557 Ok(())
1558 }
1559
1560 fn tick_visuals(&mut self) {
1561 if !self.booted {
1562 self.boot_progress = self.boot_progress.saturating_add(1);
1563 if self.boot_progress >= 70 {
1564 self.boot();
1565 }
1566 }
1567 if self.cost_flash_frames > 0 {
1568 self.cost_flash_frames -= 1;
1569 }
1570 if self.tok_flash_frames > 0 {
1571 self.tok_flash_frames -= 1;
1572 }
1573 if let Some(toast) = self.toast.as_mut() {
1574 toast.age = toast.age.saturating_add(1);
1575 if toast.age >= toast.max_age {
1576 self.toast = None;
1577 }
1578 }
1579 for ember in &mut self.embers {
1580 ember.y -= ember.vy;
1581 ember.life = ember.life.saturating_add(1);
1582 if ember.life >= ember.max_life || ember.y < 0.0 {
1583 ember.y = 28.0 + (ember.x % 7) as f32;
1584 ember.life = 0;
1585 }
1586 }
1587 }
1588
1589 fn drain_engine_events(&mut self) {
1590 let mut disconnected = false;
1591 let mut events = Vec::new();
1592 if let Some(rx) = self.event_rx.as_mut() {
1593 loop {
1594 match rx.try_recv() {
1595 Ok(event) => events.push(event),
1596 Err(mpsc::error::TryRecvError::Empty) => break,
1597 Err(mpsc::error::TryRecvError::Disconnected) => {
1598 disconnected = true;
1599 break;
1600 }
1601 }
1602 }
1603 }
1604 for event in events {
1605 self.push_event(event);
1606 }
1607 if disconnected {
1608 self.event_rx = None;
1609 self.add_line("runtime event stream disconnected", LogStyle::Warn, 0);
1610 }
1611 }
1612
1613 fn render(&self, f: &mut Frame, _elapsed: f64) {
1614 let area = f.area();
1615 if !self.booted {
1616 self.render_boot(f, area);
1617 return;
1618 }
1619 let suggestions = self.autocomplete_matches();
1621 let input_height = (self.input_lines.len() as u16 + 2).max(3)
1622 + if !suggestions.is_empty() { 1 } else { 0 };
1623 let swarm_height = if self.swarm_lanes.is_some() { 5 } else { 0 };
1624 let diff_height = if self.pending_diffs.is_empty() { 0 } else { 12 };
1625 let checkpoint_height = if self.checkpoints.is_empty() { 0 } else { 2 };
1626 let chunks = Layout::default()
1627 .direction(Direction::Vertical)
1628 .constraints([
1629 Constraint::Length(3),
1630 Constraint::Length(swarm_height),
1631 Constraint::Min(0),
1632 Constraint::Length(diff_height),
1633 Constraint::Length(checkpoint_height),
1634 Constraint::Length(1), Constraint::Length(input_height),
1636 ])
1637 .split(area);
1638 self.render_cockpit(f, chunks[0]);
1639 if swarm_height > 0 {
1640 self.render_swarm_lanes(f, chunks[1]);
1641 }
1642 self.render_scroll(f, chunks[2]);
1643 if diff_height > 0 {
1644 self.render_diff(f, chunks[3]);
1645 }
1646 if checkpoint_height > 0 {
1647 self.render_checkpoint_timeline(f, chunks[4]);
1648 }
1649 self.render_keyboard_hints(f, chunks[5]);
1650 self.render_input(f, chunks[6]);
1651 self.render_toast(f, area);
1652 }
1653
1654 fn render_boot(&self, f: &mut Frame, area: Rect) {
1655 let mut lines = Vec::new();
1656 let bird_lines: Vec<&str> = theme::ASCII_SPARROW.lines().collect();
1657 let bird_count = ((self.boot_progress / 5) as usize).min(bird_lines.len());
1658 for line in bird_lines.iter().take(bird_count) {
1659 lines.push(Line::from(Span::styled(
1660 *line,
1661 Style::default().fg(self.theme.brand),
1662 )));
1663 }
1664 if self.boot_progress >= 25 {
1665 let wordmark = if self.boot_progress < 35 {
1666 "S P A R R O W"
1667 } else if self.boot_progress < 45 {
1668 "S P A R R O W"
1669 } else {
1670 "SPARROW"
1671 };
1672 lines.push(Line::from(Span::styled(
1673 wordmark,
1674 Style::default()
1675 .fg(self.theme.brand)
1676 .add_modifier(Modifier::BOLD),
1677 )));
1678 }
1679 #[cfg(target_os = "linux")]
1680 let sandbox_boot = "sandbox local-hardened · namespaces armed";
1681 #[cfg(not(target_os = "linux"))]
1682 let sandbox_boot = "sandbox path-boundary enforcement";
1683 let boot_log = [
1684 "router warming provider graph",
1685 "surfaces cli · webview · gateway",
1686 sandbox_boot,
1687 "skills library indexed",
1688 "memory sqlite profile loaded",
1689 "autonomy dial ready",
1690 ];
1691 if self.boot_progress >= 45 {
1692 let count = (((self.boot_progress - 45) / 4) as usize).min(boot_log.len());
1693 for item in boot_log.iter().take(count) {
1694 lines.push(Line::from(Span::styled(
1695 *item,
1696 Style::default().fg(self.theme.dim),
1697 )));
1698 }
1699 }
1700 if self.boot_progress >= 68 {
1701 lines.push(Line::from(Span::styled(
1702 "✓ ready",
1703 Style::default()
1704 .fg(self.theme.add)
1705 .add_modifier(Modifier::BOLD),
1706 )));
1707 }
1708 let height = lines.len() as u16;
1709 let width = area.width.min(72);
1710 let rect = Rect {
1711 x: area.x + area.width.saturating_sub(width) / 2,
1712 y: area.y + area.height.saturating_sub(height.max(1)) / 2,
1713 width,
1714 height: height.max(1),
1715 };
1716 f.render_widget(Paragraph::new(Text::from(lines)), rect);
1717 }
1718
1719 fn render_cockpit(&self, f: &mut Frame, area: Rect) {
1720 let aut_color = match self.autonomy.as_str() {
1721 "autonomous" => self.theme.autonomous,
1722 "trusted" => self.theme.trusted,
1723 _ => self.theme.supervised,
1724 };
1725
1726 let spinner = self.theme.spinner_frame(self.spinner_idx);
1728 let verb = self.theme.flight_verb(self.frame as usize / 25);
1729
1730 let led = if self.frame / 8 % 2 == 0 {
1732 "●"
1733 } else {
1734 "◉"
1735 };
1736
1737 let cost_str = if self.cost_usd > 0.0 {
1743 format!("${:.4} ▲ ", self.cost_usd)
1744 } else {
1745 format!("${:.4} ", self.cost_usd)
1746 };
1747 let tok_str = format!("{} tok ", self.total_tokens);
1748 let aut_upper = self.autonomy.to_uppercase();
1749 let right_w = (cost_str.chars().count()
1751 + tok_str.chars().count()
1752 + 2 + aut_upper.chars().count()
1754 + 1) as u16;
1755
1756 let right = Line::from(vec![
1757 Span::styled(
1758 cost_str,
1759 if self.cost_flash_frames > 0 {
1760 Style::default()
1761 .fg(self.theme.gold)
1762 .add_modifier(Modifier::BOLD)
1763 } else {
1764 Style::default().fg(self.theme.brand)
1765 },
1766 ),
1767 Span::styled(
1769 tok_str,
1770 if self.tok_flash_frames > 0 {
1771 Style::default()
1772 .fg(self.theme.gold)
1773 .add_modifier(Modifier::BOLD)
1774 } else {
1775 Style::default().fg(self.theme.steel)
1776 },
1777 ),
1778 Span::styled(
1780 format!("{} ", led),
1781 Style::default().fg(aut_color).add_modifier(Modifier::BOLD),
1782 ),
1783 Span::styled(
1784 aut_upper,
1785 Style::default().fg(aut_color).add_modifier(Modifier::BOLD),
1786 ),
1787 ]);
1788
1789 let block = Block::default()
1792 .borders(Borders::ALL)
1793 .border_style(Style::default().fg(self.theme.line));
1794 let inner = block.inner(area);
1795 f.render_widget(block, area);
1796 let zones = Layout::default()
1797 .direction(Direction::Horizontal)
1798 .constraints([Constraint::Min(0), Constraint::Length(right_w)])
1799 .split(inner);
1800
1801 let agent_badge = match &self.active_agent {
1806 Some(agent) => format!("🐦 {} ", agent.to_uppercase()),
1808 None => String::new(),
1809 };
1810 let agent_w = if agent_badge.is_empty() {
1811 0
1812 } else {
1813 agent_badge.chars().count() + 1 };
1815 let prefix_w = 2 + 9 + 11 + agent_w + 7;
1818 let route_budget = (zones[0].width as usize).saturating_sub(prefix_w);
1819 let route_disp = truncate_for_width(&self.route, route_budget);
1820 let left = Line::from(vec![
1821 Span::styled(
1822 format!("{} ", spinner),
1823 Style::default()
1824 .fg(self.theme.brand)
1825 .add_modifier(Modifier::BOLD),
1826 ),
1827 Span::styled(
1828 "SPARROW ",
1829 Style::default()
1830 .fg(self.theme.brand)
1831 .add_modifier(Modifier::BOLD),
1832 ),
1833 Span::styled(
1834 format!("{:<9} ", verb),
1835 Style::default().fg(self.theme.dim),
1836 ),
1837 Span::styled(
1838 agent_badge,
1839 Style::default()
1840 .fg(self.theme.gold)
1841 .add_modifier(Modifier::BOLD),
1842 ),
1843 Span::styled(
1844 format!("route: {}", route_disp),
1845 Style::default().fg(self.theme.planner),
1846 ),
1847 ]);
1848 f.render_widget(Paragraph::new(left), zones[0]);
1849 f.render_widget(
1850 Paragraph::new(right).alignment(ratatui::layout::Alignment::Right),
1851 zones[1],
1852 );
1853 }
1854
1855 fn render_swarm_lanes(&self, f: &mut Frame, area: Rect) {
1856 let Some(lanes) = &self.swarm_lanes else {
1857 return;
1858 };
1859 let cols = Layout::default()
1860 .direction(Direction::Horizontal)
1861 .constraints([
1862 Constraint::Percentage(33),
1863 Constraint::Percentage(34),
1864 Constraint::Percentage(33),
1865 ])
1866 .split(area);
1867 let age = self.frame.saturating_sub(lanes.started_at_frame);
1868 let items = [
1869 ("planner", &lanes.planner, self.theme.planner),
1870 ("coder", &lanes.coder, self.theme.agent),
1871 ("verifier", &lanes.verifier, self.theme.verifier),
1872 ];
1873 for (idx, (role, lane, color)) in items.iter().enumerate() {
1874 let working = lane.status == "Working" || lane.status == "Thinking";
1875 let icon = match lane.status.as_str() {
1876 "Done" => "✓",
1877 "Error" => "✗",
1878 "Idle" => "◌",
1879 _ if self.frame / 8 % 2 == 0 => "●",
1880 _ => "○",
1881 };
1882 let caret = if working && self.frame / 8 % 2 == 0 {
1883 " ▌"
1884 } else {
1885 ""
1886 };
1887 let note_width = cols[idx].width.saturating_sub(4) as usize;
1888 let note = truncate_for_width(&lane.note, note_width);
1889 let lines = vec![
1890 Line::from(Span::styled(
1891 format!("{} {}", role.to_uppercase(), lane.model),
1892 Style::default().fg(*color).add_modifier(Modifier::BOLD),
1893 )),
1894 Line::from(Span::styled(
1895 format!("{} {}{}", icon, lane.status, caret),
1896 Style::default().fg(if working { self.theme.gold } else { *color }),
1897 )),
1898 Line::from(Span::styled(note, Style::default().fg(self.theme.fg))),
1899 ];
1900 f.render_widget(
1901 Paragraph::new(Text::from(lines)).block(
1902 Block::default()
1903 .borders(Borders::ALL)
1904 .title(format!("swarm {}", age.min(99)))
1905 .border_style(Style::default().fg(*color)),
1906 ),
1907 cols[idx],
1908 );
1909 }
1910 }
1911
1912 fn render_scroll(&self, f: &mut Frame, area: Rect) {
1913 let max_lines = area.height.saturating_sub(2) as usize;
1914 if max_lines == 0 {
1915 return;
1916 }
1917 let rendered: Vec<Line> = self
1919 .lines
1920 .iter()
1921 .filter_map(|log| {
1922 if let Some(g) = log.group {
1924 if self.groups.get(g).map(|gr| gr.collapsed).unwrap_or(false) {
1925 return None;
1926 }
1927 }
1928 if let Some(gid) = log.header_for {
1929 let gr = self.groups.get(gid);
1931 let collapsed = gr.map(|g| g.collapsed).unwrap_or(false);
1932 let title = gr.map(|g| g.title.as_str()).unwrap_or(log.text.as_str());
1933 let log_style = gr.map(|g| g.style).unwrap_or(log.style);
1934 let arrow = if collapsed { "▸" } else { "▾" };
1935 let focused = self.focus_group == Some(gid);
1936 let n = self.group_child_count(gid);
1937 let hint = if collapsed && n > 0 {
1938 format!(" ({} hidden)", n)
1939 } else {
1940 String::new()
1941 };
1942 let marker = if focused { "‣ " } else { " " };
1943 let mut style = Style::default().fg(log_style.color(&self.theme));
1944 if focused {
1945 style = style.add_modifier(Modifier::BOLD | Modifier::UNDERLINED);
1946 }
1947 Some(Line::from(Span::styled(
1948 format!("{}{} {}{}", marker, arrow, title, hint),
1949 style,
1950 )))
1951 } else {
1952 let formatted = self.format_line(&log.text);
1953 let prefix = " ".repeat(log.indent as usize);
1954 let rendered_line = crate::tui::ansi_bridge::render_line(
1955 &formatted,
1956 Style::default().fg(log.style.color(&self.theme)),
1957 );
1958 let mut final_spans =
1960 vec![Span::styled(prefix, Style::default().fg(self.theme.dim))];
1961 final_spans.extend(rendered_line.spans);
1962 Some(Line::from(final_spans))
1963 }
1964 })
1965 .collect();
1966
1967 let total = rendered.len();
1968 let skip = (self.scroll as usize).min(total.saturating_sub(1));
1969 let show_logo = self.frame.saturating_sub(70) < 120 && self.scroll == 0;
1970 let logo_lines: Vec<Line> = if show_logo {
1971 theme::ascii_sparrow_at_frame(self.frame)
1972 .lines()
1973 .map(|line| {
1974 Line::from(Span::styled(
1975 line.to_string(),
1976 Style::default().fg(self.theme.brand),
1977 ))
1978 })
1979 .collect()
1980 } else {
1981 Vec::new()
1982 };
1983 let remaining = max_lines.saturating_sub(logo_lines.len());
1984 let mut text_lines: Vec<Line> = logo_lines;
1985 let start = total.saturating_sub(skip).saturating_sub(remaining);
1986 let end = total.saturating_sub(skip);
1987 text_lines.extend(rendered[start..end].iter().cloned());
1988 f.render_widget(
1989 Paragraph::new(Text::from(text_lines)).block(
1990 Block::default()
1991 .borders(Borders::ALL)
1992 .border_style(Style::default().fg(self.theme.line)),
1993 ),
1994 area,
1995 );
1996 self.render_embers(f, area);
1997 }
1998
1999 fn render_embers(&self, f: &mut Frame, area: Rect) {
2000 if area.width < 3 || area.height < 3 {
2001 return;
2002 }
2003 for ember in &self.embers {
2004 let x = area.x + 1 + (ember.x % area.width.saturating_sub(2));
2005 let y_offset = (ember.y.max(0.0) as u16) % area.height.saturating_sub(2);
2006 let y = area.y + 1 + y_offset;
2007 let color = if ember.amber {
2008 self.theme.gold
2009 } else {
2010 self.theme.rem
2011 };
2012 if let Some(cell) = f.buffer_mut().cell_mut((x, y)) {
2013 cell.set_char(ember.glyph).set_fg(color);
2014 }
2015 }
2016 }
2017
2018 fn render_diff(&self, f: &mut Frame, area: Rect) {
2019 let Some(diff) = self.pending_diffs.back() else {
2020 return;
2021 };
2022 let mut lines = vec![Line::from(vec![
2023 Span::styled("◇ ", Style::default().fg(self.theme.gold)),
2024 Span::styled(
2025 truncate_for_width(&diff.file, area.width.saturating_sub(20) as usize),
2026 Style::default()
2027 .fg(self.theme.brand)
2028 .add_modifier(Modifier::BOLD),
2029 ),
2030 Span::styled(
2031 format!(" +{} / -{} · proposed", diff.plus, diff.minus),
2032 Style::default().fg(self.theme.dim),
2033 ),
2034 ])];
2035 for (idx, line) in diff
2036 .lines
2037 .iter()
2038 .take(area.height.saturating_sub(3) as usize)
2039 .enumerate()
2040 {
2041 let color = match line.kind {
2042 DiffLineKind::Plus => self.theme.add,
2043 DiffLineKind::Minus => self.theme.rem,
2044 DiffLineKind::Hunk => self.theme.gold,
2045 DiffLineKind::Context => self.theme.dim,
2046 };
2047 let mut spans = vec![Span::styled(
2048 format!("{:>4} ", idx + 1),
2049 Style::default().fg(self.theme.dimmer),
2050 )];
2051 spans.extend(syntax_spans(&line.text, &self.theme, color));
2052 lines.push(Line::from(spans));
2053 }
2054 f.render_widget(
2055 Paragraph::new(Text::from(lines)).block(
2056 Block::default()
2057 .borders(Borders::ALL)
2058 .title("diff")
2059 .border_style(Style::default().fg(self.theme.line)),
2060 ),
2061 area,
2062 );
2063 }
2064
2065 fn render_checkpoint_timeline(&self, f: &mut Frame, area: Rect) {
2066 let mut spans = Vec::new();
2067 for (idx, node) in self
2068 .checkpoints
2069 .iter()
2070 .rev()
2071 .take(8)
2072 .collect::<Vec<_>>()
2073 .iter()
2074 .rev()
2075 .enumerate()
2076 {
2077 if idx > 0 {
2078 spans.push(Span::styled("──", Style::default().fg(self.theme.dimmer)));
2079 }
2080 spans.push(Span::styled(
2081 if node.current { "●" } else { "◆" },
2082 Style::default().fg(if node.current {
2083 self.theme.gold
2084 } else {
2085 self.theme.dim
2086 }),
2087 ));
2088 }
2089 if let Some(current) = self.checkpoints.iter().find(|n| n.current) {
2090 spans.push(Span::styled(
2091 format!(
2092 " {} · {}",
2093 truncate_for_width(¤t.label, 36),
2094 current.id.chars().take(8).collect::<String>()
2095 ),
2096 Style::default().fg(self.theme.dim),
2097 ));
2098 }
2099 spans.push(Span::styled(
2100 " rewind ← · snapshot before each batch",
2101 Style::default().fg(self.theme.dimmer),
2102 ));
2103 f.render_widget(Paragraph::new(Line::from(spans)), area);
2104 }
2105
2106 fn render_toast(&self, f: &mut Frame, area: Rect) {
2107 let Some(toast) = &self.toast else {
2108 return;
2109 };
2110 let width = (toast.text.chars().count() as u16 + 6).min(area.width.saturating_sub(2));
2111 if width < 8 || area.height < 5 {
2112 return;
2113 }
2114 let rect = Rect {
2115 x: area.x + area.width.saturating_sub(width) / 2,
2116 y: area.y + area.height.saturating_sub(3) / 2,
2117 width,
2118 height: 3,
2119 };
2120 let border = if toast.age / 20 % 2 == 0 {
2121 Style::default()
2122 .fg(self.theme.gold)
2123 .add_modifier(Modifier::BOLD)
2124 } else {
2125 Style::default().fg(self.theme.gold)
2126 };
2127 f.render_widget(
2128 Paragraph::new(Line::from(Span::styled(
2129 toast.text.as_str(),
2130 Style::default()
2131 .fg(self.theme.gold)
2132 .add_modifier(Modifier::BOLD),
2133 )))
2134 .block(Block::default().borders(Borders::ALL).border_style(border)),
2135 rect,
2136 );
2137 }
2138
2139 fn render_keyboard_hints(&self, f: &mut Frame, area: Rect) {
2140 let hints =
2141 format!("Esc:quit Tab:agents /:search @:skills Ctrl+R:run Ctrl+C:stop F1:help",);
2142 let line = Line::from(Span::styled(hints, Style::default().fg(self.theme.dimmer)));
2143 f.render_widget(
2144 Paragraph::new(line).alignment(ratatui::layout::Alignment::Center),
2145 area,
2146 );
2147 }
2148
2149 fn render_input(&self, f: &mut Frame, area: Rect) {
2150 let cursor_char = if self.frame / 8 % 2 == 0 { "▌" } else { " " };
2151 let prompt = if self.inject_pending {
2152 "◆ inject › "
2153 } else {
2154 "◆ sparrow › "
2155 };
2156 let prompt_color = if self.inject_pending {
2157 self.theme.coral
2158 } else {
2159 self.theme.brand
2160 };
2161
2162 let mut text_lines: Vec<Line> = Vec::new();
2163 for (row_idx, line) in self.input_lines.iter().enumerate() {
2164 let mut spans: Vec<Span> = Vec::new();
2165 if row_idx == 0 {
2166 spans.push(Span::styled(
2167 prompt,
2168 Style::default()
2169 .fg(prompt_color)
2170 .add_modifier(Modifier::BOLD),
2171 ));
2172 } else {
2173 spans.push(Span::styled(
2174 " › ",
2175 Style::default().fg(self.theme.dimmer),
2176 ));
2177 }
2178 if row_idx == self.cursor_row {
2179 let (before, after) = line.split_at(self.cursor_col.min(line.len()));
2180 spans.push(Span::styled(before, Style::default().fg(self.theme.fg)));
2181 spans.push(Span::styled(cursor_char, Style::default().fg(prompt_color)));
2182 spans.push(Span::styled(after, Style::default().fg(self.theme.fg)));
2183 } else {
2184 spans.push(Span::styled(
2185 line.as_str(),
2186 Style::default().fg(self.theme.fg),
2187 ));
2188 }
2189 text_lines.push(Line::from(spans));
2190 }
2191
2192 let suggestions = self.autocomplete_matches();
2194 if !suggestions.is_empty() {
2195 let mut s: Vec<Span> = vec![Span::styled(
2196 " ⇥ ",
2197 Style::default().fg(self.theme.dimmer),
2198 )];
2199 for (i, cmd) in suggestions.iter().enumerate() {
2200 if i == 0 {
2201 s.push(Span::styled(
2202 *cmd,
2203 Style::default()
2204 .fg(self.theme.brand)
2205 .add_modifier(Modifier::BOLD),
2206 ));
2207 } else {
2208 s.push(Span::styled(*cmd, Style::default().fg(self.theme.dim)));
2209 }
2210 s.push(Span::raw(" "));
2211 }
2212 text_lines.push(Line::from(s));
2213 }
2214
2215 f.render_widget(
2216 Paragraph::new(Text::from(text_lines)).block(
2217 Block::default()
2218 .borders(Borders::ALL)
2219 .border_style(Style::default().fg(self.theme.line)),
2220 ),
2221 area,
2222 );
2223 }
2224}
2225
2226impl Default for Tui {
2227 fn default() -> Self {
2228 Self::new()
2229 }
2230}
2231
2232#[cfg(test)]
2233mod v09_tests {
2234 use super::*;
2235 use crate::event::{Event, OutcomeSummary, RunId, TokenUsage};
2236
2237 fn run() -> RunId {
2238 RunId("t".into())
2239 }
2240
2241 #[test]
2242 fn simple_mode_renders_human_model_switch() {
2243 let mut tui = Tui::new().with_experience(true, crate::humanize::Lang::Fr);
2244 let before = tui.lines.len();
2245 tui.push_event(Event::ModelSwitched {
2246 run: run(),
2247 from: "a".into(),
2248 to: "b".into(),
2249 reason: "escalation".into(),
2250 });
2251 let added: Vec<String> = tui.lines[before..].iter().map(|l| l.text.clone()).collect();
2252 let joined = added.join("\n");
2253 assert!(
2254 joined.contains("vitesse supérieure") || joined.contains("Je change de modèle"),
2255 "simple mode should show a human switch line, got: {joined}"
2256 );
2257 assert!(!joined.contains("fallback:"), "jargon leaked: {joined}");
2259 }
2260
2261 #[test]
2262 fn pro_mode_keeps_technical_model_switch() {
2263 let mut tui = Tui::new().with_experience(false, crate::humanize::Lang::Fr);
2264 let before = tui.lines.len();
2265 tui.push_event(Event::ModelSwitched {
2266 run: run(),
2267 from: "a".into(),
2268 to: "b".into(),
2269 reason: "x".into(),
2270 });
2271 let joined: String = tui.lines[before..]
2272 .iter()
2273 .map(|l| l.text.clone())
2274 .collect::<Vec<_>>()
2275 .join("\n");
2276 assert!(joined.contains("a") && joined.contains("b"));
2277 }
2278
2279 #[test]
2280 fn simple_mode_run_finished_has_no_dollar_jargon() {
2281 let mut tui = Tui::new().with_experience(true, crate::humanize::Lang::Fr);
2282 let before = tui.lines.len();
2283 tui.push_event(Event::RunFinished {
2284 run: run(),
2285 outcome: OutcomeSummary {
2286 status: "completed".into(),
2287 diffs: vec![],
2288 cost_usd: 0.0,
2289 tokens: TokenUsage {
2290 input: 0,
2291 output: 0,
2292 },
2293 cost_comparison: String::new(),
2294 duration_ms: None,
2295 },
2296 });
2297 let joined: String = tui.lines[before..]
2298 .iter()
2299 .map(|l| l.text.clone())
2300 .collect::<Vec<_>>()
2301 .join("\n");
2302 assert!(
2303 joined.contains("Terminé") || joined.contains("gratuit"),
2304 "got: {joined}"
2305 );
2306 assert!(
2307 !joined.contains("status:"),
2308 "technical status leaked: {joined}"
2309 );
2310 }
2311}