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