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