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