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