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