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