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;
23pub mod ansi_bridge;
24
25type CrosstermTerminal = ratatui::Terminal<ratatui::backend::CrosstermBackend<io::Stdout>>;
26
27#[derive(Debug, Clone)]
28struct LogLine {
29 text: String,
30 style: LogStyle,
31 indent: u16,
32 group: Option<usize>,
34 header_for: Option<usize>,
36}
37
38#[derive(Debug, Clone)]
40struct TaskGroup {
41 title: String,
42 collapsed: bool,
43 style: LogStyle,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq)]
47enum LogStyle {
48 Normal,
49 Dim,
50 Brand,
51 Agent,
52 Planner,
53 Verifier,
54 Rem,
55 Steel,
56 Gold,
57 Prompt,
58 Cmd,
59 Ok,
60 Warn,
61 Err,
62 Accent,
63}
64
65impl LogStyle {
66 fn color(&self, theme: &Theme) -> Color {
67 match self {
68 LogStyle::Normal => theme.fg,
69 LogStyle::Dim => theme.dim,
70 LogStyle::Brand => theme.brand,
71 LogStyle::Agent => theme.agent,
72 LogStyle::Planner => theme.planner,
73 LogStyle::Verifier => theme.verifier,
74 LogStyle::Rem => theme.rem,
75 LogStyle::Steel => theme.steel,
76 LogStyle::Gold => theme.gold,
77 LogStyle::Prompt => theme.brand,
78 LogStyle::Cmd => theme.fg,
79 LogStyle::Ok => theme.add,
80 LogStyle::Warn => theme.verifier,
81 LogStyle::Err => theme.rem,
82 LogStyle::Accent => theme.brand,
83 }
84 }
85}
86
87const SLASH_COMMANDS: &[&str] = &[
88 "/help",
89 "/plan",
90 "/permissions",
91 "/memory",
92 "/compact",
93 "/model",
94 "/agents",
95 "/sessions",
96 "/export",
97 "/run",
98 "/chat",
99 "/swarm",
100 "/agent",
101 "/skills",
102 "/checkpoint",
103 "/rewind",
104 "/replay",
105 "/auth",
106 "/clear",
107 "/collapse",
108 "/expand",
109 "/exit",
110];
111
112const HISTORY_MAX: usize = 100;
113
114#[derive(Debug, Clone)]
117struct LaneState {
118 status: String,
120 note: String,
122 model: String,
124}
125
126impl Default for LaneState {
127 fn default() -> Self {
128 Self {
129 status: "Idle".into(),
130 note: "".into(),
131 model: "".into(),
132 }
133 }
134}
135
136#[derive(Debug, Clone, Default)]
137struct SwarmLanesState {
138 planner: LaneState,
139 coder: LaneState,
140 verifier: LaneState,
141 started_at_frame: u64,
143}
144
145#[derive(Debug, Clone)]
148enum DiffLineKind {
149 Context,
150 Plus,
151 Minus,
152 Hunk,
153}
154
155#[derive(Debug, Clone)]
156struct DiffLineEntry {
157 kind: DiffLineKind,
158 text: String,
159}
160
161#[derive(Debug, Clone)]
162struct DiffEntry {
163 file: String,
164 plus: u32,
165 minus: u32,
166 lines: Vec<DiffLineEntry>,
167 applied: bool,
168}
169
170fn parse_diff_patch(patch: &str) -> Vec<DiffLineEntry> {
171 let mut out = Vec::new();
172 for line in patch.lines().take(40) {
173 let kind = if line.starts_with("+++") || line.starts_with("---") {
174 DiffLineKind::Context
175 } else if line.starts_with("@@") {
176 DiffLineKind::Hunk
177 } else if line.starts_with('+') {
178 DiffLineKind::Plus
179 } else if line.starts_with('-') {
180 DiffLineKind::Minus
181 } else {
182 DiffLineKind::Context
183 };
184 out.push(DiffLineEntry {
185 kind,
186 text: line.to_string(),
187 });
188 }
189 out
190}
191
192fn truncate_for_width(text: &str, width: usize) -> String {
193 if width == 0 {
194 return String::new();
195 }
196 let mut out = String::new();
197 for ch in text.chars().take(width) {
198 out.push(ch);
199 }
200 if text.chars().count() > width && width > 1 {
201 out.pop();
202 out.push('…');
203 }
204 out
205}
206
207fn syntax_spans(text: &str, theme: &Theme, base: Color) -> Vec<Span<'static>> {
208 const KEYWORDS: &[&str] = &[
209 "fn", "pub", "if", "else", "return", "let", "mut", "const", "struct", "impl", "trait",
210 "use", "as", "match",
211 ];
212 let violet = Color::Rgb(0xb4, 0x8e, 0xff);
213 let mut spans = Vec::new();
214 let mut buf = String::new();
215 let chars = text.chars();
216 let mut in_string = false;
217
218 let flush_word = |word: &mut String, spans: &mut Vec<Span<'static>>, next_is_call: bool| {
219 if word.is_empty() {
220 return;
221 }
222 let style = if KEYWORDS.contains(&word.as_str()) {
223 Style::default().fg(violet).add_modifier(Modifier::BOLD)
224 } else if next_is_call {
225 Style::default().fg(theme.gold)
226 } else {
227 Style::default().fg(base)
228 };
229 spans.push(Span::styled(std::mem::take(word), style));
230 };
231
232 for ch in chars {
233 if ch == '"' {
234 if in_string {
235 buf.push(ch);
236 spans.push(Span::styled(
237 std::mem::take(&mut buf),
238 Style::default().fg(theme.add),
239 ));
240 in_string = false;
241 } else {
242 flush_word(&mut buf, &mut spans, false);
243 buf.push(ch);
244 in_string = true;
245 }
246 continue;
247 }
248 if in_string {
249 buf.push(ch);
250 continue;
251 }
252 if ch.is_alphanumeric() || ch == '_' {
253 buf.push(ch);
254 continue;
255 }
256 let next_is_call = ch == '(';
257 flush_word(&mut buf, &mut spans, next_is_call);
258 spans.push(Span::styled(ch.to_string(), Style::default().fg(base)));
259 }
260 if in_string {
261 spans.push(Span::styled(buf, Style::default().fg(theme.add)));
262 } else {
263 flush_word(&mut buf, &mut spans, false);
264 }
265 spans
266}
267
268#[derive(Debug, Clone)]
271struct CheckpointNode {
272 id: String,
273 label: String,
274 current: bool,
275}
276
277#[derive(Debug, Clone)]
280struct Ember {
281 x: u16,
282 y: f32,
283 vy: f32,
284 amber: bool,
286 life: u32,
287 max_life: u32,
288 glyph: char,
290}
291
292#[derive(Debug, Clone)]
295struct Toast {
296 text: String,
297 age: u32,
299 max_age: u32,
301}
302
303pub struct Tui {
304 theme: Theme,
305 lines: Vec<LogLine>,
306 route: String,
307 cost_usd: f64,
308 total_tokens: u64,
309 autonomy: String,
310 input_lines: Vec<String>,
312 cursor_row: usize,
314 cursor_col: usize,
316 history: Vec<String>,
318 history_idx: Option<usize>,
320 inject_pending: bool,
322 scroll: u16,
323 frame: u64,
324 spinner_idx: usize,
325 booted: bool,
326 boot_progress: u32,
327 event_rx: Option<mpsc::UnboundedReceiver<Event>>,
328 task_tx: Option<mpsc::UnboundedSender<String>>,
329 history_path: Option<std::path::PathBuf>,
330
331 swarm_lanes: Option<SwarmLanesState>,
334 pending_diffs: std::collections::VecDeque<DiffEntry>,
336 checkpoints: Vec<CheckpointNode>,
338 embers: Vec<Ember>,
340 toast: Option<Toast>,
342 cost_flash_frames: u32,
344 last_cost: f64,
345 tok_flash_frames: u32,
347 last_tokens: u64,
348
349 groups: Vec<TaskGroup>,
352 current_group: Option<usize>,
354 focus_group: Option<usize>,
356
357 replay_events: Option<Vec<Event>>,
360 replay_idx: usize,
361 think: crate::event::ThinkStripper,
363 agent_names: Vec<String>,
365 active_agent: Option<String>,
367 agent_souls: std::collections::HashMap<String, (String, String)>,
369 term_renderer: crate::tui::renderer::TermRenderer,
371}
372
373impl Tui {
374 pub fn new() -> Self {
375 let history_path = dirs::state_dir()
377 .or_else(dirs::data_local_dir)
378 .or_else(dirs::data_dir)
379 .map(|d| d.join("sparrow").join("tui_history.txt"));
380 let history = history_path
381 .as_ref()
382 .and_then(|p| std::fs::read_to_string(p).ok())
383 .map(|s| s.lines().map(String::from).collect())
384 .unwrap_or_default();
385
386 let theme = std::env::var("SPARROW_THEME")
388 .ok()
389 .map(|n| crate::tui::theme::by_name(&n))
390 .unwrap_or_default();
391 Self {
392 theme,
393 lines: Vec::new(),
394 route: "idle".into(),
395 cost_usd: 0.0,
396 total_tokens: 0,
397 autonomy: "supervised".into(),
398 input_lines: vec![String::new()],
399 cursor_row: 0,
400 cursor_col: 0,
401 history,
402 history_idx: None,
403 inject_pending: false,
404 scroll: 0,
405 frame: 0,
406 spinner_idx: 0,
407 booted: false,
408 boot_progress: 0,
409 event_rx: None,
410 task_tx: None,
411 history_path,
412 swarm_lanes: None,
413 pending_diffs: std::collections::VecDeque::new(),
414 checkpoints: Vec::new(),
415 embers: Self::spawn_embers(),
416 toast: None,
417 cost_flash_frames: 0,
418 last_cost: 0.0,
419 tok_flash_frames: 0,
420 last_tokens: 0,
421 groups: Vec::new(),
422 current_group: None,
423 focus_group: None,
424 replay_events: None,
425 replay_idx: 0,
426 think: crate::event::ThinkStripper::new(),
427 agent_names: Vec::new(),
428 active_agent: None,
429 agent_souls: std::collections::HashMap::new(),
430 term_renderer: crate::tui::renderer::TermRenderer::new(
431 crate::tui::renderer::RenderConfig::default(),
432 ),
433 }
434 }
435
436 pub fn with_replay(mut self, events: Vec<Event>) -> Self {
439 self.replay_events = Some(events);
440 self.replay_idx = 0;
441 self.booted = true; self
443 }
444
445 fn rebuild_replay(&mut self) {
447 let Some(events) = self.replay_events.clone() else {
448 return;
449 };
450 self.lines.clear();
451 self.groups.clear();
452 self.current_group = None;
453 self.focus_group = None;
454 self.cost_usd = 0.0;
455 self.total_tokens = 0;
456 let upto = self.replay_idx.min(events.len());
457 for ev in events.iter().take(upto) {
458 self.push_event(ev.clone());
459 }
460 let total = events.len();
461 self.add_line(
462 &format!(
463 "── replay {}/{} (←/→ step · Home/End jump · q quit) ──",
464 upto, total
465 ),
466 LogStyle::Accent,
467 0,
468 );
469 }
470
471 fn spawn_embers() -> Vec<Ember> {
472 let glyphs = ['·', '•', '∘', '◦'];
474 (0..10u16)
475 .map(|i| Ember {
476 x: 4 + (i * 13) % 90,
477 y: 4.0 + ((i as f32) * 2.7) % 20.0,
478 vy: 0.10 + ((i as f32) * 0.037) % 0.25,
479 amber: i % 2 == 0,
480 life: ((i as u32) * 17) % 180,
481 max_life: 180 + ((i as u32) * 11) % 90,
482 glyph: glyphs[(i as usize) % glyphs.len()],
483 })
484 .collect()
485 }
486
487 fn current_input(&self) -> String {
489 self.input_lines.join("\n")
490 }
491
492 fn set_input(&mut self, s: &str) {
494 self.input_lines = s.split('\n').map(String::from).collect();
495 if self.input_lines.is_empty() {
496 self.input_lines.push(String::new());
497 }
498 self.cursor_row = self.input_lines.len() - 1;
499 self.cursor_col = self.input_lines[self.cursor_row].len();
500 }
501
502 fn push_history(&mut self, entry: &str) {
504 if entry.trim().is_empty() {
505 return;
506 }
507 if self.history.last().map(|s| s.as_str()) == Some(entry) {
508 return;
509 }
510 self.history.push(entry.to_string());
511 if self.history.len() > HISTORY_MAX {
512 let excess = self.history.len() - HISTORY_MAX;
513 self.history.drain(..excess);
514 }
515 if let Some(path) = &self.history_path {
516 if let Some(parent) = path.parent() {
517 let _ = std::fs::create_dir_all(parent);
518 }
519 let _ = std::fs::write(path, self.history.join("\n"));
520 }
521 }
522
523 fn autocomplete_matches(&self) -> Vec<&'static str> {
525 let line = &self.input_lines[0];
526 if line.starts_with('/') {
527 return SLASH_COMMANDS
528 .iter()
529 .filter(|c| c.starts_with(line.as_str()) && **c != line.as_str())
530 .copied()
531 .take(5)
532 .collect();
533 }
534 vec![]
535 }
536
537 #[doc(hidden)]
539 pub fn debug_first_line_mut(&mut self) -> &mut String {
540 if self.input_lines.is_empty() {
541 self.input_lines.push(String::new());
542 }
543 &mut self.input_lines[0]
544 }
545
546 #[doc(hidden)]
548 pub fn debug_set_cursor_col(&mut self, col: usize) {
549 self.cursor_row = 0;
550 self.cursor_col = col;
551 }
552
553 pub fn agent_matches(&self) -> Vec<String> {
556 let line = &self.input_lines[self.cursor_row];
558 let upto = line.get(..self.cursor_col).unwrap_or(line);
559 let Some(at_pos) = upto.rfind('@') else {
560 return vec![];
561 };
562 if at_pos > 0
565 && !upto[..at_pos]
566 .chars()
567 .last()
568 .map(|c| c.is_whitespace())
569 .unwrap_or(true)
570 {
571 return vec![];
572 }
573 let prefix = &upto[at_pos + 1..];
574 if prefix.contains(char::is_whitespace) {
576 return vec![];
577 }
578 self.agent_names
579 .iter()
580 .filter(|n| n.starts_with(prefix))
581 .take(5)
582 .map(|n| format!("@{}", n))
583 .collect()
584 }
585
586 pub fn with_agents(mut self, names: Vec<String>) -> Self {
588 self.agent_names = names;
589 self
590 }
591
592 pub fn toggle_agent(&mut self, name: &str) {
595 if self.active_agent.as_deref() == Some(name) {
596 self.active_agent = None;
598 } else {
599 self.active_agent = Some(name.to_string());
601 if !self.agent_souls.contains_key(name) {
602 self.cache_agent_soul(name);
603 }
604 }
605 }
606
607 fn cache_agent_soul(&mut self, name: &str) {
609 let path = dirs::config_dir()
610 .unwrap_or_default()
611 .join("sparrow")
612 .join("agents")
613 .join(format!("{}.soul.toml", name));
614 if let Ok(content) = std::fs::read_to_string(&path) {
615 let role = content.lines()
616 .find(|l| l.starts_with("role"))
617 .and_then(|l| l.split('=').nth(1))
618 .map(|s| s.trim().trim_matches('"').to_string())
619 .unwrap_or_default();
620 let personality = content.lines()
621 .find(|l| l.starts_with("personality"))
622 .and_then(|l| l.split('=').nth(1))
623 .map(|s| s.trim().trim_matches('"').to_string())
624 .unwrap_or_default();
625 use base64::{Engine as _, engine::general_purpose::STANDARD};
626 let b64 = STANDARD.encode(personality.as_bytes());
627 self.agent_souls.insert(name.to_string(), (role, b64));
628 }
629 }
630
631 fn agent_prefix(&self) -> String {
633 if let Some(ref name) = self.active_agent {
634 if let Some((role, b64)) = self.agent_souls.get(name) {
635 return format!("__agent:{}__{}__{}__ ", name, role, b64);
636 }
637 }
638 String::new()
639 }
640
641 pub fn with_channels(
642 mut self,
643 task_tx: mpsc::UnboundedSender<String>,
644 event_rx: mpsc::UnboundedReceiver<Event>,
645 ) -> Self {
646 self.task_tx = Some(task_tx);
647 self.event_rx = Some(event_rx);
648 self
649 }
650
651 fn format_line(&self, text: &str) -> String {
654 let trimmed = text.trim();
656
657 if trimmed.starts_with("```") || text.lines().all(|l| l.starts_with(" ") || l.is_empty()) {
659 return self.term_renderer.render_code(text, "");
660 }
661
662 if trimmed.contains("diff --git") || trimmed.starts_with("@@") || trimmed.starts_with("--- a/") || trimmed.starts_with("+++ b/") {
664 return self.term_renderer.render_diff(text);
665 }
666
667 if trimmed.starts_with('{') || trimmed.starts_with('[') {
669 if serde_json::from_str::<serde_json::Value>(trimmed).is_ok() {
670 return self.term_renderer.render_json(text);
671 }
672 }
673
674 if trimmed.starts_with("# ") || trimmed.starts_with("## ") || trimmed.starts_with("### ") {
676 return self.term_renderer.render_markdown(text);
677 }
678
679 text.to_string()
681 }
682
683 pub fn push_event(&mut self, event: Event) {
684 match &event {
685 Event::RunStarted { task, .. } => {
686 self.think = crate::event::ThinkStripper::new();
687 self.open_group(&format!("started: {}", task), LogStyle::Brand);
688 }
689 Event::RouteSelected { chain, .. } => {
690 self.route = chain.join(" → ");
691 self.add_line(&format!("↳ route: {}", self.route), LogStyle::Dim, 1);
692 }
693 Event::ModelSwitched {
694 from, to, reason, ..
695 } => {
696 self.route = to.clone();
697 let clean = crate::event::friendly_model_switch_reason(reason);
698 let label = if crate::event::is_local_model_unavailable(reason) {
699 format!(
700 "↳ modèle local indisponible → routage modèle cloud ({})",
701 to
702 )
703 } else {
704 format!("↳ fallback: {} → {} ({})", from, to, clean)
705 };
706 self.add_line(&label, LogStyle::Warn, 1);
707 }
708 Event::ThinkingDelta { text, .. } => {
709 let visible = self.think.feed(text);
710 if !visible.is_empty() {
711 self.add_line(&visible, LogStyle::Cmd, 1);
712 }
713 }
714 Event::ReasoningDelta { .. } => {}
715 Event::ToolUseProposed { name, .. } => {
716 self.open_group(&format!("tool · {}", name), LogStyle::Steel);
717 }
718 Event::ToolOutput { blocks, .. } => {
719 for b in blocks {
720 if let crate::event::Block::Text(t) = b {
721 self.add_line(&format!(" {}", t), LogStyle::Dim, 2);
722 }
723 }
724 }
725 Event::AgentSpawned { role, model, .. } => {
726 let lanes = self.swarm_lanes.get_or_insert_with(|| SwarmLanesState {
727 started_at_frame: self.frame,
728 ..Default::default()
729 });
730 let lane = match role.as_str() {
731 "planner" => &mut lanes.planner,
732 "coder" => &mut lanes.coder,
733 "verifier" => &mut lanes.verifier,
734 _ => &mut lanes.coder,
735 };
736 lane.status = "Working".into();
737 lane.note = "spawned".into();
738 lane.model = model.clone();
739 let s = match role.as_str() {
740 "planner" => LogStyle::Planner,
741 "coder" => LogStyle::Agent,
742 "verifier" => LogStyle::Verifier,
743 _ => LogStyle::Dim,
744 };
745 self.open_group(&format!("{} ({})", role, model), s);
746 }
747 Event::AgentStatus {
748 role, note, status, ..
749 } => {
750 if let Some(lanes) = self.swarm_lanes.as_mut() {
751 let lane = match role.as_str() {
752 "planner" => &mut lanes.planner,
753 "coder" => &mut lanes.coder,
754 "verifier" => &mut lanes.verifier,
755 _ => &mut lanes.coder,
756 };
757 lane.status = format!("{:?}", status);
758 lane.note = note.clone();
759 }
760 let s = match role.as_str() {
761 "planner" => LogStyle::Planner,
762 "coder" => LogStyle::Agent,
763 "verifier" => LogStyle::Verifier,
764 _ => LogStyle::Dim,
765 };
766 let icon = match status {
767 crate::event::AgentStatus::Done => "✓",
768 crate::event::AgentStatus::Working => "●",
769 crate::event::AgentStatus::Thinking => "○",
770 crate::event::AgentStatus::Error => "✗",
771 _ => "◌",
772 };
773 self.add_line(&format!("{} {} — {}", icon, role, note), s, 1);
774 }
775 Event::CheckpointCreated { id, label, .. } => {
776 for node in &mut self.checkpoints {
777 node.current = false;
778 }
779 self.checkpoints.push(CheckpointNode {
780 id: id.0.clone(),
781 label: label.clone(),
782 current: true,
783 });
784 self.add_line(&format!("● checkpoint: {}", label), LogStyle::Gold, 0)
785 }
786 Event::SkillLearned { name, .. } => {
787 self.toast = Some(Toast {
788 text: format!("✦ skill learned · {}", name),
789 age: 0,
790 max_age: 90,
791 });
792 self.add_line(&format!("✦ skill learned · {}", name), LogStyle::Agent, 0)
793 }
794 Event::CostUpdate { usd, .. } => {
795 if *usd > self.last_cost {
796 self.cost_flash_frames = 12;
797 }
798 self.last_cost = *usd;
799 self.cost_usd = *usd;
800 }
801 Event::TokenUsage { input, output, .. } => {
802 self.total_tokens += input + output;
803 if self.total_tokens > self.last_tokens {
804 self.tok_flash_frames = 12;
805 }
806 self.last_tokens = self.total_tokens;
807 }
808 Event::TokenUsageEstimated { input, output, .. } => {
809 self.total_tokens += input + output;
810 if self.total_tokens > self.last_tokens {
811 self.tok_flash_frames = 12;
812 }
813 self.last_tokens = self.total_tokens;
814 }
815 Event::AutonomyChanged { level, .. } => {
816 self.autonomy = format!("{:?}", level).to_lowercase()
817 }
818 Event::DiffProposed {
819 file,
820 patch,
821 plus,
822 minus,
823 ..
824 } => {
825 if self.pending_diffs.len() >= 3 {
826 self.pending_diffs.pop_front();
827 }
828 self.pending_diffs.push_back(DiffEntry {
829 file: file.clone(),
830 plus: *plus,
831 minus: *minus,
832 lines: parse_diff_patch(patch),
833 applied: false,
834 });
835 self.add_line(
836 &format!("◇ {} +{} / -{} · proposed", file, plus, minus),
837 LogStyle::Dim,
838 0,
839 )
840 }
841 Event::DiffApplied { file, .. } => {
842 if let Some(entry) = self.pending_diffs.iter_mut().find(|d| d.file == *file) {
843 entry.applied = true;
844 }
845 while self.pending_diffs.front().is_some_and(|d| d.applied) {
846 self.pending_diffs.pop_front();
847 }
848 }
849 Event::TestResult {
850 passed,
851 failed,
852 detail,
853 ..
854 } => {
855 if *failed > 0 {
856 self.add_line(
857 &format!("⚠ tests {} passed · {} failed", passed, failed),
858 LogStyle::Warn,
859 1,
860 );
861 for line in detail.lines() {
862 self.add_line(&format!(" {}", line), LogStyle::Rem, 2);
863 }
864 } else {
865 self.add_line(
866 &format!("✓ tests {} passed · no regressions", passed),
867 LogStyle::Ok,
868 1,
869 );
870 }
871 }
872 Event::RunFinished { outcome, .. } => {
873 let tail = self.think.flush();
875 if !tail.trim().is_empty() {
876 self.add_line(&tail, LogStyle::Cmd, 1);
877 }
878 self.close_group();
879 self.add_line(
880 &format!(
881 "✓ done status: {} cost: ${:.4}",
882 outcome.status, outcome.cost_usd
883 ),
884 LogStyle::Ok,
885 0,
886 );
887 }
888 Event::Error { message, .. } => {
889 if !crate::event::is_local_model_unavailable(message) {
890 self.add_line(message, LogStyle::Err, 0);
891 }
892 }
893 _ => {}
894 }
895 }
896
897 fn add_line(&mut self, text: &str, style: LogStyle, indent: u16) {
898 let group = self.current_group;
899 for line in text.lines() {
900 self.lines.push(LogLine {
901 text: line.to_string(),
902 style,
903 indent,
904 group,
905 header_for: None,
906 });
907 }
908 }
909
910 fn open_group(&mut self, title: &str, style: LogStyle) {
912 let id = self.groups.len();
913 self.groups.push(TaskGroup {
914 title: title.to_string(),
915 collapsed: false,
916 style,
917 });
918 self.lines.push(LogLine {
919 text: title.to_string(),
920 style,
921 indent: 0,
922 group: None,
923 header_for: Some(id),
924 });
925 self.current_group = Some(id);
926 self.focus_group = Some(id);
927 }
928
929 fn close_group(&mut self) {
931 self.current_group = None;
932 }
933
934 fn group_child_count(&self, id: usize) -> usize {
936 self.lines.iter().filter(|l| l.group == Some(id)).count()
937 }
938
939 fn focus_group_step(&mut self, forward: bool) {
941 if self.groups.is_empty() {
942 return;
943 }
944 let last = self.groups.len() - 1;
945 self.focus_group = Some(match self.focus_group {
946 None => last,
947 Some(i) if forward => (i + 1).min(last),
948 Some(i) => i.saturating_sub(1),
949 });
950 }
951
952 fn toggle_group(&mut self) {
954 match self.focus_group {
955 Some(i) if i < self.groups.len() => {
956 self.groups[i].collapsed = !self.groups[i].collapsed;
957 }
958 _ => {
959 let any_open = self.groups.iter().any(|g| !g.collapsed);
960 for g in &mut self.groups {
961 g.collapsed = any_open;
962 }
963 }
964 }
965 }
966
967 fn boot(&mut self) {
968 self.add_line(
969 "SPARROW v0.1.0 — one cli · grows with you",
970 LogStyle::Dim,
971 0,
972 );
973 self.add_line("", LogStyle::Normal, 0);
974
975 #[cfg(target_os = "linux")]
978 let sandbox_line = "local-hardened · namespaces + path boundary";
979 #[cfg(not(target_os = "linux"))]
980 let sandbox_line = "path-boundary enforcement (namespaces are Linux-only)";
981
982 let boot = [
983 (
984 "router ",
985 "model routing + fallback chain",
986 LogStyle::Planner,
987 ),
988 (
989 "surfaces",
990 "cli · tui · webview · gateway",
991 LogStyle::Planner,
992 ),
993 ("sandbox ", sandbox_line, LogStyle::Ok),
994 (
995 "skills ",
996 "library indexed · self-improving",
997 LogStyle::Accent,
998 ),
999 (
1000 "memory ",
1001 "sqlite · bounded docs · session search",
1002 LogStyle::Ok,
1003 ),
1004 (
1005 "autonomy",
1006 "dial: supervised → trusted → autonomous",
1007 LogStyle::Accent,
1008 ),
1009 ];
1010 for (k, v, s) in &boot {
1011 self.add_line(&format!("{} {}", k, v), *s, 1);
1012 }
1013 self.add_line("✓ ready one binary. no dependencies.", LogStyle::Ok, 0);
1014 self.add_line("", LogStyle::Normal, 0);
1015 self.booted = true;
1016 }
1017
1018 pub fn run(&mut self) -> io::Result<()> {
1019 enable_raw_mode()?;
1020 let mut stdout = io::stdout();
1021 execute!(stdout, EnterAlternateScreen)?;
1022 let backend = ratatui::backend::CrosstermBackend::new(stdout);
1023 let mut terminal = ratatui::Terminal::new(backend)?;
1024 let result = self.main_loop(&mut terminal);
1025 disable_raw_mode()?;
1026 execute!(io::stdout(), LeaveAlternateScreen)?;
1027 result
1028 }
1029
1030 fn main_loop(&mut self, terminal: &mut CrosstermTerminal) -> io::Result<()> {
1031 let start = Instant::now();
1032 if self.replay_events.is_some() {
1033 self.rebuild_replay();
1034 }
1035 loop {
1036 self.drain_engine_events();
1037 self.frame += 1;
1038 self.spinner_idx = (self.spinner_idx + 1) % 10;
1039 self.tick_visuals();
1040 terminal.draw(|f| self.render(f, start.elapsed().as_secs_f64()))?;
1041 if event::poll(std::time::Duration::from_millis(50))? {
1042 if let TermEvent::Key(key) = event::read()? {
1043 if key.kind != KeyEventKind::Press {
1044 continue;
1045 }
1046 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1047 let shift = key.modifiers.contains(KeyModifiers::SHIFT);
1048 match key.code {
1049 KeyCode::Esc => break,
1050 KeyCode::Char('c') if ctrl => break,
1051
1052 KeyCode::Char('q') if self.replay_events.is_some() => break,
1054 KeyCode::Left if self.replay_events.is_some() => {
1055 self.replay_idx = self.replay_idx.saturating_sub(1);
1056 self.rebuild_replay();
1057 }
1058 KeyCode::Right if self.replay_events.is_some() => {
1059 let max = self.replay_events.as_ref().map(|e| e.len()).unwrap_or(0);
1060 self.replay_idx = (self.replay_idx + 1).min(max);
1061 self.rebuild_replay();
1062 }
1063 KeyCode::Home if self.replay_events.is_some() => {
1064 self.replay_idx = 0;
1065 self.rebuild_replay();
1066 }
1067 KeyCode::End if self.replay_events.is_some() => {
1068 self.replay_idx =
1069 self.replay_events.as_ref().map(|e| e.len()).unwrap_or(0);
1070 self.rebuild_replay();
1071 }
1072
1073 KeyCode::Char('l') if ctrl => {
1075 self.lines.clear();
1076 }
1077 KeyCode::Char('i') if ctrl => {
1079 self.inject_pending = true;
1080 self.add_line(
1081 "[inject] next message will be sent to the running agent",
1082 LogStyle::Warn,
1083 0,
1084 );
1085 }
1086
1087 KeyCode::Up if ctrl => self.focus_group_step(false),
1090 KeyCode::Down if ctrl => self.focus_group_step(true),
1091 KeyCode::Char('o') if ctrl => self.toggle_group(),
1092
1093 KeyCode::Up if self.cursor_row == 0 && !self.history.is_empty() => {
1095 let new_idx = match self.history_idx {
1096 None => self.history.len() - 1,
1097 Some(0) => 0,
1098 Some(i) => i - 1,
1099 };
1100 self.history_idx = Some(new_idx);
1101 let entry = self.history[new_idx].clone();
1102 self.set_input(&entry);
1103 }
1104 KeyCode::Down if self.cursor_row == self.input_lines.len() - 1 => {
1105 match self.history_idx {
1106 Some(i) if i + 1 < self.history.len() => {
1107 self.history_idx = Some(i + 1);
1108 let entry = self.history[i + 1].clone();
1109 self.set_input(&entry);
1110 }
1111 Some(_) => {
1112 self.history_idx = None;
1113 self.set_input("");
1114 }
1115 None => {}
1116 }
1117 }
1118
1119 KeyCode::PageUp => self.scroll = self.scroll.saturating_add(10),
1121 KeyCode::PageDown => self.scroll = self.scroll.saturating_sub(10),
1122 KeyCode::Home => self.scroll = 0,
1123 KeyCode::End => self.scroll = u16::MAX,
1124
1125 KeyCode::Tab => {
1127 let line = &self.input_lines[0];
1128 if line.starts_with('@') {
1130 let name = &line[1..].trim().to_string();
1131 if !name.is_empty() && self.agent_names.contains(name) {
1132 self.toggle_agent(name);
1133 self.input_lines = vec![String::new()];
1134 self.cursor_row = 0;
1135 self.cursor_col = 0;
1136 }
1137 } else {
1138 let matches = self.autocomplete_matches();
1139 if let Some(first) = matches.first() {
1140 self.input_lines = vec![first.to_string()];
1141 self.cursor_row = 0;
1142 self.cursor_col = first.len();
1143 }
1144 }
1145 }
1146
1147 KeyCode::Backspace => {
1149 if self.cursor_col > 0 {
1150 let line = &mut self.input_lines[self.cursor_row];
1151 let new_col = line[..self.cursor_col]
1152 .char_indices()
1153 .last()
1154 .map(|(i, _)| i)
1155 .unwrap_or(0);
1156 line.replace_range(new_col..self.cursor_col, "");
1157 self.cursor_col = new_col;
1158 } else if self.cursor_row > 0 {
1159 let curr = self.input_lines.remove(self.cursor_row);
1161 self.cursor_row -= 1;
1162 let prev = &mut self.input_lines[self.cursor_row];
1163 self.cursor_col = prev.len();
1164 prev.push_str(&curr);
1165 }
1166 }
1167
1168 KeyCode::Enter if shift || key.modifiers.contains(KeyModifiers::ALT) => {
1170 let line = &mut self.input_lines[self.cursor_row];
1171 let rest = line.split_off(self.cursor_col);
1172 self.cursor_row += 1;
1173 self.cursor_col = 0;
1174 self.input_lines.insert(self.cursor_row, rest);
1175 }
1176
1177 KeyCode::Enter => {
1179 let task = self.current_input().trim().to_string();
1180 if !task.is_empty() {
1181 match task.as_str() {
1183 "/clear" => {
1184 self.lines.clear();
1185 self.groups.clear();
1186 self.current_group = None;
1187 self.focus_group = None;
1188 }
1189 "/collapse" => {
1190 for g in &mut self.groups {
1191 g.collapsed = true;
1192 }
1193 }
1194 "/expand" => {
1195 for g in &mut self.groups {
1196 g.collapsed = false;
1197 }
1198 }
1199 "/exit" | "/quit" => break,
1200 "/help" => {
1201 self.add_line("Commands:", LogStyle::Brand, 0);
1202 for c in SLASH_COMMANDS {
1203 self.add_line(c, LogStyle::Dim, 1);
1204 }
1205 self.add_line(
1206 "Ctrl+I inject · Ctrl+L clear · Ctrl+↑/↓ focus task · Ctrl+O fold/unfold · Shift+Enter newline · Up/Down history",
1207 LogStyle::Dim, 0,
1208 );
1209 self.add_line(
1210 "/collapse · /expand — fold/unfold all tasks",
1211 LogStyle::Dim,
1212 1,
1213 );
1214 }
1215 s if s.starts_with("/plan") => {
1216 let planned = s.trim_start_matches("/plan").trim();
1217 if planned.is_empty() {
1218 self.add_line("Usage: /plan <task>", LogStyle::Warn, 0);
1219 } else {
1220 let plan =
1221 crate::plan::build_read_only_plan(planned, &[]);
1222 self.add_line(
1223 "Read-only plan · no tools or edits executed",
1224 LogStyle::Planner,
1225 0,
1226 );
1227 self.add_line(&plan.summary, LogStyle::Dim, 1);
1228 for (idx, step) in plan.steps.iter().enumerate() {
1229 self.add_line(
1230 &format!("{}. {}", idx + 1, step),
1231 LogStyle::Cmd,
1232 1,
1233 );
1234 }
1235 self.add_line(
1236 "Run the task explicitly when you accept the plan.",
1237 LogStyle::Warn,
1238 0,
1239 );
1240 }
1241 }
1242 _ => {
1243 let label = if self.inject_pending {
1245 "inject"
1246 } else {
1247 "sparrow"
1248 };
1249 self.add_line(
1250 &format!("{} › {}", label, task.replace('\n', " ↵ ")),
1251 LogStyle::Prompt,
1252 0,
1253 );
1254 self.push_history(&task);
1255 let to_send = if self.inject_pending {
1256 format!("__inject__:{}", task)
1257 } else {
1258 let prefix = self.agent_prefix();
1259 if prefix.is_empty() {
1260 task.clone()
1261 } else {
1262 format!("{}{}", prefix, task)
1263 }
1264 };
1265 self.inject_pending = false;
1266 if let Some(tx) = &self.task_tx {
1267 if tx.send(to_send).is_err() {
1268 self.add_line(
1269 "runtime channel disconnected",
1270 LogStyle::Err,
1271 0,
1272 );
1273 }
1274 }
1275 }
1276 }
1277 self.set_input("");
1278 self.history_idx = None;
1279 }
1280 }
1281
1282 KeyCode::Char(c) => {
1284 let line = &mut self.input_lines[self.cursor_row];
1285 line.insert(self.cursor_col, c);
1286 self.cursor_col += c.len_utf8();
1287 }
1288
1289 KeyCode::Left => {
1291 if self.scroll == 0
1292 && self.cursor_col == 0
1293 && self.checkpoints.len() > 1
1294 {
1295 let previous = self
1296 .checkpoints
1297 .iter()
1298 .rev()
1299 .skip(1)
1300 .find(|node| !node.id.is_empty())
1301 .map(|node| node.id.clone());
1302 if let (Some(id), Some(tx)) = (previous, &self.task_tx) {
1303 let _ = tx.send(format!("__rewind__:{}", id));
1304 self.add_line(
1305 "rewind requested from checkpoint timeline",
1306 LogStyle::Gold,
1307 0,
1308 );
1309 }
1310 } else if self.cursor_col > 0 {
1311 self.cursor_col = self.input_lines[self.cursor_row]
1312 [..self.cursor_col]
1313 .char_indices()
1314 .last()
1315 .map(|(i, _)| i)
1316 .unwrap_or(0);
1317 } else if self.cursor_row > 0 {
1318 self.cursor_row -= 1;
1319 self.cursor_col = self.input_lines[self.cursor_row].len();
1320 }
1321 }
1322 KeyCode::Right => {
1323 let line = &self.input_lines[self.cursor_row];
1324 if self.cursor_col < line.len() {
1325 let next = line[self.cursor_col..]
1326 .chars()
1327 .next()
1328 .map(|c| c.len_utf8())
1329 .unwrap_or(0);
1330 self.cursor_col += next;
1331 } else if self.cursor_row + 1 < self.input_lines.len() {
1332 self.cursor_row += 1;
1333 self.cursor_col = 0;
1334 }
1335 }
1336
1337 _ => {}
1338 }
1339 }
1340 }
1341 }
1342 Ok(())
1343 }
1344
1345 fn tick_visuals(&mut self) {
1346 if !self.booted {
1347 self.boot_progress = self.boot_progress.saturating_add(1);
1348 if self.boot_progress >= 70 {
1349 self.boot();
1350 }
1351 }
1352 if self.cost_flash_frames > 0 {
1353 self.cost_flash_frames -= 1;
1354 }
1355 if self.tok_flash_frames > 0 {
1356 self.tok_flash_frames -= 1;
1357 }
1358 if let Some(toast) = self.toast.as_mut() {
1359 toast.age = toast.age.saturating_add(1);
1360 if toast.age >= toast.max_age {
1361 self.toast = None;
1362 }
1363 }
1364 for ember in &mut self.embers {
1365 ember.y -= ember.vy;
1366 ember.life = ember.life.saturating_add(1);
1367 if ember.life >= ember.max_life || ember.y < 0.0 {
1368 ember.y = 28.0 + (ember.x % 7) as f32;
1369 ember.life = 0;
1370 }
1371 }
1372 }
1373
1374 fn drain_engine_events(&mut self) {
1375 let mut disconnected = false;
1376 let mut events = Vec::new();
1377 if let Some(rx) = self.event_rx.as_mut() {
1378 loop {
1379 match rx.try_recv() {
1380 Ok(event) => events.push(event),
1381 Err(mpsc::error::TryRecvError::Empty) => break,
1382 Err(mpsc::error::TryRecvError::Disconnected) => {
1383 disconnected = true;
1384 break;
1385 }
1386 }
1387 }
1388 }
1389 for event in events {
1390 self.push_event(event);
1391 }
1392 if disconnected {
1393 self.event_rx = None;
1394 self.add_line("runtime event stream disconnected", LogStyle::Warn, 0);
1395 }
1396 }
1397
1398 fn render(&self, f: &mut Frame, _elapsed: f64) {
1399 let area = f.area();
1400 if !self.booted {
1401 self.render_boot(f, area);
1402 return;
1403 }
1404 let suggestions = self.autocomplete_matches();
1406 let input_height = (self.input_lines.len() as u16 + 2).max(3)
1407 + if !suggestions.is_empty() { 1 } else { 0 };
1408 let swarm_height = if self.swarm_lanes.is_some() { 5 } else { 0 };
1409 let diff_height = if self.pending_diffs.is_empty() { 0 } else { 12 };
1410 let checkpoint_height = if self.checkpoints.is_empty() { 0 } else { 2 };
1411 let chunks = Layout::default()
1412 .direction(Direction::Vertical)
1413 .constraints([
1414 Constraint::Length(3),
1415 Constraint::Length(swarm_height),
1416 Constraint::Min(0),
1417 Constraint::Length(diff_height),
1418 Constraint::Length(checkpoint_height),
1419 Constraint::Length(input_height),
1420 ])
1421 .split(area);
1422 self.render_cockpit(f, chunks[0]);
1423 if swarm_height > 0 {
1424 self.render_swarm_lanes(f, chunks[1]);
1425 }
1426 self.render_scroll(f, chunks[2]);
1427 if diff_height > 0 {
1428 self.render_diff(f, chunks[3]);
1429 }
1430 if checkpoint_height > 0 {
1431 self.render_checkpoint_timeline(f, chunks[4]);
1432 }
1433 self.render_input(f, chunks[5]);
1434 self.render_toast(f, area);
1435 }
1436
1437 fn render_boot(&self, f: &mut Frame, area: Rect) {
1438 let mut lines = Vec::new();
1439 let bird_lines: Vec<&str> = theme::ASCII_SPARROW.lines().collect();
1440 let bird_count = ((self.boot_progress / 5) as usize).min(bird_lines.len());
1441 for line in bird_lines.iter().take(bird_count) {
1442 lines.push(Line::from(Span::styled(
1443 *line,
1444 Style::default().fg(self.theme.brand),
1445 )));
1446 }
1447 if self.boot_progress >= 25 {
1448 let wordmark = if self.boot_progress < 35 {
1449 "S P A R R O W"
1450 } else if self.boot_progress < 45 {
1451 "S P A R R O W"
1452 } else {
1453 "SPARROW"
1454 };
1455 lines.push(Line::from(Span::styled(
1456 wordmark,
1457 Style::default()
1458 .fg(self.theme.brand)
1459 .add_modifier(Modifier::BOLD),
1460 )));
1461 }
1462 #[cfg(target_os = "linux")]
1463 let sandbox_boot = "sandbox local-hardened · namespaces armed";
1464 #[cfg(not(target_os = "linux"))]
1465 let sandbox_boot = "sandbox path-boundary enforcement";
1466 let boot_log = [
1467 "router warming provider graph",
1468 "surfaces cli · webview · gateway",
1469 sandbox_boot,
1470 "skills library indexed",
1471 "memory sqlite profile loaded",
1472 "autonomy dial ready",
1473 ];
1474 if self.boot_progress >= 45 {
1475 let count = (((self.boot_progress - 45) / 4) as usize).min(boot_log.len());
1476 for item in boot_log.iter().take(count) {
1477 lines.push(Line::from(Span::styled(
1478 *item,
1479 Style::default().fg(self.theme.dim),
1480 )));
1481 }
1482 }
1483 if self.boot_progress >= 68 {
1484 lines.push(Line::from(Span::styled(
1485 "✓ ready",
1486 Style::default()
1487 .fg(self.theme.add)
1488 .add_modifier(Modifier::BOLD),
1489 )));
1490 }
1491 let height = lines.len() as u16;
1492 let width = area.width.min(72);
1493 let rect = Rect {
1494 x: area.x + area.width.saturating_sub(width) / 2,
1495 y: area.y + area.height.saturating_sub(height.max(1)) / 2,
1496 width,
1497 height: height.max(1),
1498 };
1499 f.render_widget(Paragraph::new(Text::from(lines)), rect);
1500 }
1501
1502 fn render_cockpit(&self, f: &mut Frame, area: Rect) {
1503 let aut_color = match self.autonomy.as_str() {
1504 "autonomous" => self.theme.autonomous,
1505 "trusted" => self.theme.trusted,
1506 _ => self.theme.supervised,
1507 };
1508
1509 let spinner = self.theme.spinner_frame(self.spinner_idx);
1511 let verb = self.theme.flight_verb(self.frame as usize / 25);
1512
1513 let led = if self.frame / 8 % 2 == 0 {
1515 "●"
1516 } else {
1517 "◉"
1518 };
1519
1520 let line = Line::from(vec![
1521 Span::styled(
1523 format!("{} ", spinner),
1524 Style::default()
1525 .fg(self.theme.brand)
1526 .add_modifier(Modifier::BOLD),
1527 ),
1528 Span::styled(
1530 "SPARROW ",
1531 Style::default()
1532 .fg(self.theme.brand)
1533 .add_modifier(Modifier::BOLD),
1534 ),
1535 Span::styled(
1537 format!("{:<9} ", verb),
1538 Style::default().fg(self.theme.dim),
1539 ),
1540 Span::styled(
1542 if let Some(ref agent) = self.active_agent {
1543 format!("🐦 {} ", agent.to_uppercase())
1544 } else {
1545 String::new()
1546 },
1547 Style::default()
1548 .fg(self.theme.gold)
1549 .add_modifier(Modifier::BOLD),
1550 ),
1551 Span::styled(
1553 format!("route: {} ", self.route),
1554 Style::default().fg(self.theme.planner),
1555 ),
1556 Span::styled(
1558 if self.cost_usd > 0.0 {
1559 format!("${:.4} ▲ ", self.cost_usd)
1560 } else {
1561 format!("${:.4} ", self.cost_usd)
1562 },
1563 if self.cost_flash_frames > 0 {
1564 Style::default()
1565 .fg(self.theme.gold)
1566 .add_modifier(Modifier::BOLD)
1567 } else {
1568 Style::default().fg(self.theme.brand)
1569 },
1570 ),
1571 Span::styled(
1573 format!("{} tok ", self.total_tokens),
1574 if self.tok_flash_frames > 0 {
1575 Style::default()
1576 .fg(self.theme.gold)
1577 .add_modifier(Modifier::BOLD)
1578 } else {
1579 Style::default().fg(self.theme.steel)
1580 },
1581 ),
1582 Span::styled(
1584 format!("{} ", led),
1585 Style::default().fg(aut_color).add_modifier(Modifier::BOLD),
1586 ),
1587 Span::styled(
1588 self.autonomy.to_uppercase(),
1589 Style::default().fg(aut_color).add_modifier(Modifier::BOLD),
1590 ),
1591 ]);
1592 f.render_widget(
1593 Paragraph::new(line).block(
1594 Block::default()
1595 .borders(Borders::ALL)
1596 .border_style(Style::default().fg(self.theme.line)),
1597 ),
1598 area,
1599 );
1600 }
1601
1602 fn render_swarm_lanes(&self, f: &mut Frame, area: Rect) {
1603 let Some(lanes) = &self.swarm_lanes else {
1604 return;
1605 };
1606 let cols = Layout::default()
1607 .direction(Direction::Horizontal)
1608 .constraints([
1609 Constraint::Percentage(33),
1610 Constraint::Percentage(34),
1611 Constraint::Percentage(33),
1612 ])
1613 .split(area);
1614 let age = self.frame.saturating_sub(lanes.started_at_frame);
1615 let items = [
1616 ("planner", &lanes.planner, self.theme.planner),
1617 ("coder", &lanes.coder, self.theme.agent),
1618 ("verifier", &lanes.verifier, self.theme.verifier),
1619 ];
1620 for (idx, (role, lane, color)) in items.iter().enumerate() {
1621 let working = lane.status == "Working" || lane.status == "Thinking";
1622 let icon = match lane.status.as_str() {
1623 "Done" => "✓",
1624 "Error" => "✗",
1625 "Idle" => "◌",
1626 _ if self.frame / 8 % 2 == 0 => "●",
1627 _ => "○",
1628 };
1629 let caret = if working && self.frame / 8 % 2 == 0 {
1630 " ▌"
1631 } else {
1632 ""
1633 };
1634 let note_width = cols[idx].width.saturating_sub(4) as usize;
1635 let note = truncate_for_width(&lane.note, note_width);
1636 let lines = vec![
1637 Line::from(Span::styled(
1638 format!("{} {}", role.to_uppercase(), lane.model),
1639 Style::default().fg(*color).add_modifier(Modifier::BOLD),
1640 )),
1641 Line::from(Span::styled(
1642 format!("{} {}{}", icon, lane.status, caret),
1643 Style::default().fg(if working { self.theme.gold } else { *color }),
1644 )),
1645 Line::from(Span::styled(note, Style::default().fg(self.theme.fg))),
1646 ];
1647 f.render_widget(
1648 Paragraph::new(Text::from(lines)).block(
1649 Block::default()
1650 .borders(Borders::ALL)
1651 .title(format!("swarm {}", age.min(99)))
1652 .border_style(Style::default().fg(*color)),
1653 ),
1654 cols[idx],
1655 );
1656 }
1657 }
1658
1659 fn render_scroll(&self, f: &mut Frame, area: Rect) {
1660 let max_lines = area.height.saturating_sub(2) as usize;
1661 if max_lines == 0 {
1662 return;
1663 }
1664 let rendered: Vec<Line> = self
1666 .lines
1667 .iter()
1668 .filter_map(|log| {
1669 if let Some(g) = log.group {
1671 if self.groups.get(g).map(|gr| gr.collapsed).unwrap_or(false) {
1672 return None;
1673 }
1674 }
1675 if let Some(gid) = log.header_for {
1676 let gr = self.groups.get(gid);
1678 let collapsed = gr.map(|g| g.collapsed).unwrap_or(false);
1679 let title = gr.map(|g| g.title.as_str()).unwrap_or(log.text.as_str());
1680 let log_style = gr.map(|g| g.style).unwrap_or(log.style);
1681 let arrow = if collapsed { "▸" } else { "▾" };
1682 let focused = self.focus_group == Some(gid);
1683 let n = self.group_child_count(gid);
1684 let hint = if collapsed && n > 0 {
1685 format!(" ({} hidden)", n)
1686 } else {
1687 String::new()
1688 };
1689 let marker = if focused { "‣ " } else { " " };
1690 let mut style = Style::default().fg(log_style.color(&self.theme));
1691 if focused {
1692 style = style.add_modifier(Modifier::BOLD | Modifier::UNDERLINED);
1693 }
1694 Some(Line::from(Span::styled(
1695 format!("{}{} {}{}", marker, arrow, title, hint),
1696 style,
1697 )))
1698 } else {
1699 let formatted = self.format_line(&log.text);
1700 let prefix = " ".repeat(log.indent as usize);
1701 let rendered_line = crate::tui::ansi_bridge::render_line(
1702 &formatted,
1703 Style::default().fg(log.style.color(&self.theme)),
1704 );
1705 let mut final_spans = vec![Span::styled(
1707 prefix,
1708 Style::default().fg(self.theme.dim),
1709 )];
1710 final_spans.extend(rendered_line.spans);
1711 Some(Line::from(final_spans))
1712 }
1713 })
1714 .collect();
1715
1716 let total = rendered.len();
1717 let skip = (self.scroll as usize).min(total.saturating_sub(1));
1718 let show_logo = self.frame.saturating_sub(70) < 120 && self.scroll == 0;
1719 let logo_lines: Vec<Line> = if show_logo {
1720 theme::ascii_sparrow_at_frame(self.frame)
1721 .lines()
1722 .map(|line| {
1723 Line::from(Span::styled(
1724 line.to_string(),
1725 Style::default().fg(self.theme.brand),
1726 ))
1727 })
1728 .collect()
1729 } else {
1730 Vec::new()
1731 };
1732 let remaining = max_lines.saturating_sub(logo_lines.len());
1733 let mut text_lines: Vec<Line> = logo_lines;
1734 let start = total.saturating_sub(skip).saturating_sub(remaining);
1735 let end = total.saturating_sub(skip);
1736 text_lines.extend(rendered[start..end].iter().cloned());
1737 f.render_widget(
1738 Paragraph::new(Text::from(text_lines)).block(
1739 Block::default()
1740 .borders(Borders::ALL)
1741 .border_style(Style::default().fg(self.theme.line)),
1742 ),
1743 area,
1744 );
1745 self.render_embers(f, area);
1746 }
1747
1748 fn render_embers(&self, f: &mut Frame, area: Rect) {
1749 if area.width < 3 || area.height < 3 {
1750 return;
1751 }
1752 for ember in &self.embers {
1753 let x = area.x + 1 + (ember.x % area.width.saturating_sub(2));
1754 let y_offset = (ember.y.max(0.0) as u16) % area.height.saturating_sub(2);
1755 let y = area.y + 1 + y_offset;
1756 let color = if ember.amber {
1757 self.theme.gold
1758 } else {
1759 self.theme.rem
1760 };
1761 if let Some(cell) = f.buffer_mut().cell_mut((x, y)) {
1762 cell.set_char(ember.glyph).set_fg(color);
1763 }
1764 }
1765 }
1766
1767 fn render_diff(&self, f: &mut Frame, area: Rect) {
1768 let Some(diff) = self.pending_diffs.back() else {
1769 return;
1770 };
1771 let mut lines = vec![Line::from(vec![
1772 Span::styled("◇ ", Style::default().fg(self.theme.gold)),
1773 Span::styled(
1774 truncate_for_width(&diff.file, area.width.saturating_sub(20) as usize),
1775 Style::default()
1776 .fg(self.theme.brand)
1777 .add_modifier(Modifier::BOLD),
1778 ),
1779 Span::styled(
1780 format!(" +{} / -{} · proposed", diff.plus, diff.minus),
1781 Style::default().fg(self.theme.dim),
1782 ),
1783 ])];
1784 for (idx, line) in diff
1785 .lines
1786 .iter()
1787 .take(area.height.saturating_sub(3) as usize)
1788 .enumerate()
1789 {
1790 let color = match line.kind {
1791 DiffLineKind::Plus => self.theme.add,
1792 DiffLineKind::Minus => self.theme.rem,
1793 DiffLineKind::Hunk => self.theme.gold,
1794 DiffLineKind::Context => self.theme.dim,
1795 };
1796 let mut spans = vec![Span::styled(
1797 format!("{:>4} ", idx + 1),
1798 Style::default().fg(self.theme.dimmer),
1799 )];
1800 spans.extend(syntax_spans(&line.text, &self.theme, color));
1801 lines.push(Line::from(spans));
1802 }
1803 f.render_widget(
1804 Paragraph::new(Text::from(lines)).block(
1805 Block::default()
1806 .borders(Borders::ALL)
1807 .title("diff")
1808 .border_style(Style::default().fg(self.theme.line)),
1809 ),
1810 area,
1811 );
1812 }
1813
1814 fn render_checkpoint_timeline(&self, f: &mut Frame, area: Rect) {
1815 let mut spans = Vec::new();
1816 for (idx, node) in self
1817 .checkpoints
1818 .iter()
1819 .rev()
1820 .take(8)
1821 .collect::<Vec<_>>()
1822 .iter()
1823 .rev()
1824 .enumerate()
1825 {
1826 if idx > 0 {
1827 spans.push(Span::styled("──", Style::default().fg(self.theme.dimmer)));
1828 }
1829 spans.push(Span::styled(
1830 if node.current { "●" } else { "◆" },
1831 Style::default().fg(if node.current {
1832 self.theme.gold
1833 } else {
1834 self.theme.dim
1835 }),
1836 ));
1837 }
1838 if let Some(current) = self.checkpoints.iter().find(|n| n.current) {
1839 spans.push(Span::styled(
1840 format!(
1841 " {} · {}",
1842 truncate_for_width(¤t.label, 36),
1843 current.id.chars().take(8).collect::<String>()
1844 ),
1845 Style::default().fg(self.theme.dim),
1846 ));
1847 }
1848 spans.push(Span::styled(
1849 " rewind ← · snapshot before each batch",
1850 Style::default().fg(self.theme.dimmer),
1851 ));
1852 f.render_widget(Paragraph::new(Line::from(spans)), area);
1853 }
1854
1855 fn render_toast(&self, f: &mut Frame, area: Rect) {
1856 let Some(toast) = &self.toast else {
1857 return;
1858 };
1859 let width = (toast.text.chars().count() as u16 + 6).min(area.width.saturating_sub(2));
1860 if width < 8 || area.height < 5 {
1861 return;
1862 }
1863 let rect = Rect {
1864 x: area.x + area.width.saturating_sub(width) / 2,
1865 y: area.y + area.height.saturating_sub(3) / 2,
1866 width,
1867 height: 3,
1868 };
1869 let border = if toast.age / 20 % 2 == 0 {
1870 Style::default()
1871 .fg(self.theme.gold)
1872 .add_modifier(Modifier::BOLD)
1873 } else {
1874 Style::default().fg(self.theme.gold)
1875 };
1876 f.render_widget(
1877 Paragraph::new(Line::from(Span::styled(
1878 toast.text.as_str(),
1879 Style::default()
1880 .fg(self.theme.gold)
1881 .add_modifier(Modifier::BOLD),
1882 )))
1883 .block(Block::default().borders(Borders::ALL).border_style(border)),
1884 rect,
1885 );
1886 }
1887
1888 fn render_input(&self, f: &mut Frame, area: Rect) {
1889 let cursor_char = if self.frame / 8 % 2 == 0 { "▌" } else { " " };
1890 let prompt = if self.inject_pending {
1891 "◆ inject › "
1892 } else {
1893 "◆ sparrow › "
1894 };
1895 let prompt_color = if self.inject_pending {
1896 self.theme.coral
1897 } else {
1898 self.theme.brand
1899 };
1900
1901 let mut text_lines: Vec<Line> = Vec::new();
1902 for (row_idx, line) in self.input_lines.iter().enumerate() {
1903 let mut spans: Vec<Span> = Vec::new();
1904 if row_idx == 0 {
1905 spans.push(Span::styled(
1906 prompt,
1907 Style::default()
1908 .fg(prompt_color)
1909 .add_modifier(Modifier::BOLD),
1910 ));
1911 } else {
1912 spans.push(Span::styled(
1913 " › ",
1914 Style::default().fg(self.theme.dimmer),
1915 ));
1916 }
1917 if row_idx == self.cursor_row {
1918 let (before, after) = line.split_at(self.cursor_col.min(line.len()));
1919 spans.push(Span::styled(before, Style::default().fg(self.theme.fg)));
1920 spans.push(Span::styled(cursor_char, Style::default().fg(prompt_color)));
1921 spans.push(Span::styled(after, Style::default().fg(self.theme.fg)));
1922 } else {
1923 spans.push(Span::styled(
1924 line.as_str(),
1925 Style::default().fg(self.theme.fg),
1926 ));
1927 }
1928 text_lines.push(Line::from(spans));
1929 }
1930
1931 let suggestions = self.autocomplete_matches();
1933 if !suggestions.is_empty() {
1934 let mut s: Vec<Span> = vec![Span::styled(
1935 " ⇥ ",
1936 Style::default().fg(self.theme.dimmer),
1937 )];
1938 for (i, cmd) in suggestions.iter().enumerate() {
1939 if i == 0 {
1940 s.push(Span::styled(
1941 *cmd,
1942 Style::default()
1943 .fg(self.theme.brand)
1944 .add_modifier(Modifier::BOLD),
1945 ));
1946 } else {
1947 s.push(Span::styled(*cmd, Style::default().fg(self.theme.dim)));
1948 }
1949 s.push(Span::raw(" "));
1950 }
1951 text_lines.push(Line::from(s));
1952 }
1953
1954 f.render_widget(
1955 Paragraph::new(Text::from(text_lines)).block(
1956 Block::default()
1957 .borders(Borders::ALL)
1958 .border_style(Style::default().fg(self.theme.line)),
1959 ),
1960 area,
1961 );
1962 }
1963}
1964
1965impl Default for Tui {
1966 fn default() -> Self {
1967 Self::new()
1968 }
1969}